image.png

jvm运行期间起数据区域主要可以分为下面几块:程序计数器,方法区,虚拟机栈,本地方法栈,和堆。而我们平时主要提到的是堆和栈,这里的栈就是指的我们的虚拟机栈。运行过程中,除了程序计数器,其他区域当内存不够用时都可能产生OOM。下面我们将讲一下每种内存区域的作用,并模拟下他们各自产生OOM的场景。

内存区域划分

程序计数器

程序计数器是一块较小的内存空间,用于记录当前线程字节码执行到多少行了,在多线程的情况下,线程切换后要恢复到之前正确的执行位置,就依赖这个计数器的记录,因此,每个线程都会有自己独立的一个程序计数器,程序计数器的内存属于线程私有的内存,因为记录的只是一个或多个固定数量的数值,内存大小对于jvm而言是可控的,所以程序计数器不会出现内存溢出的情况。

虚拟机栈

方法栈也是线程私有的,在线程运行过程中,每一个方法被执行时都会为之创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息,我们常说的堆栈中的“栈”就是指的栈帧中的局部变量表。如果线程请求的深度大于虚拟及所允许的深度,就会抛出StackOverFlowError异常,当虚拟机拓展时无法申请到足够的空间则会抛出OutOfMemeryError。

本地方法栈

本地方法栈和虚拟机栈所发挥的作用非常相似,只不过本地方法栈是为虚拟机使用到的Native方法服务。本地方法栈也会抛出StackOverFlowError和OutOfMemeryError。

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动的时候创建。此区域唯一的目的就是存放对象的实例。所有对象的实例以及数组都要在堆上分配(栈上分配技术的出现让这句话变得没有那么绝对了)。Java堆还可细分为新生代和老年代,新生代又可以细分为Eden,From survivor,To survivor。同样,如果堆内存无法完成实例分配也无法拓展时,将会抛出OutOfMemeryError。

方法区

方法区和Java堆一样是各个线程共享的内存区域,用于存储已被java虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据,。常被程序员称作是永久代。方法区无法满足内存分配需求时会抛出OutOfMemeryError,永久代只有在Full GC的时候才会被回收,而且回收条件十分苛刻,所以在cglib等使用场景大量生成动态代理类时会发生OOM。

运行时常量池

运行时常量池是方法区的一部分,Class文件中除了有类的版本,字段,方法,接口等信息外,还有一项就是常量池。常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放在方法区得到运行时常量池里。同时运行期间也可能将新的常量放到常量池中,开发人员用得较多的便是String.intern()方法。当常量池无法申请到内存时也会抛出OOM。

直接内存

直接内存并不是java虚拟机的一部分,但是这部分内存也会被用到,同时也会抛出OOM。比如在NIO中基于Channel与Buffer的IO方式,它可以使用Native函数直接分配堆外内存,然后通过一个存储在Java 堆里的DirectByteBuffer对象作为这块内存的引用进行操作。

对于java内存区域划分和各区域的作用都在上面里 ,下面我们将通过编码的方式,模拟各个区域OOM的情况。

OOM 实战

Java堆OOM

让堆内存OOM相对简单,只需要不断的创建对象,使得堆内存不够用即可。这里我们需要了解的两个jvm参数,-Xms 堆的最小值, -Xmx 堆的最大值。为了加速OOM发生我们将堆的大小设置成20MB(-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError)

  1. public class HeapOOM {
  2. static class OOMObject{}
  3. public static void main(String[] args) {
  4. List<OOMObject> oomObjectList = new ArrayList<OOMObject>();
  5. while(true){
  6. oomObjectList.add(new OOMObject());
  7. }
  8. }
  9. }

执行结果

  1. java.lang.OutOfMemoryError: Java heap space
  2. Dumping heap to java_pid21104.hprof ...
  3. Heap dump file created [27742938 bytes in 0.218 secs]

要解决堆区的OOM我们首先要分清楚是内存溢出还是内存泄漏,可以使用专门的分析工具如MAT对dump出来的快照做分析,如果是内存泄漏可以通过GC roots的引用连分析出泄漏对象,并定位出泄漏代码的位置,如果不是泄漏,则可以调整堆大小,或者在代码层面优化对象的生命周期。后续会写一篇文章介绍mat的使用。

虚拟机栈和本地方法栈溢出

栈溢出是因为方法调用链太深,多出现于递归调用,为了加速模拟过程我们同样先了解一个参数
-Xss 栈的最大容量,通过减少栈的容量(-Xss160k),栈的深度随之也会减少,这样栈溢出的情况可以发生得更快一些。

  1. public class JavaStackSOF {
  2. int stackLength = 1;
  3. public void stackForword(){
  4. stackLength++;
  5. stackForword();
  6. }
  7. public static void main(String[] args) {
  8. JavaStackSOF javaStackSOF = new JavaStackSOF();
  9. try{
  10. javaStackSOF.stackForword();
  11. }catch (Throwable e){
  12. System.out.println("stack length : "+javaStackSOF.stackLength);
  13. throw e;
  14. }
  15. }
  16. }

执行结果

  1. stack length : 774
  2. at com.donar.javabasic.oom.JavaStackSOF.stackForword(JavaStackSOF.java:9)
  3. at com.donar.javabasic.oom.JavaStackSOF.stackForword(JavaStackSOF.java:10)
  4. at com.donar.javabasic.oom.JavaStackSOF.stackForword(JavaStackSOF.java:10)
  5. at com.donar.javabasic.oom.JavaStackSOF.stackForword(JavaStackSOF.java:10)
  6. at com.donar.javabasic.oom.JavaStackSOF.stackForword(JavaStackSOF.java:10)
  7. .....

jvm为每个线程都会分配虚拟机栈,所以当线程数目太多导致栈内存不够用时,也会抛出OOM

  1. public class JavaVMStackOOM {
  2. private void dontStop(){
  3. while (true){
  4. }
  5. }
  6. public void StackFowardByThread(){
  7. while (true){
  8. Thread t = new Thread(new Runnable() {
  9. @Override
  10. public void run() {
  11. dontStop();
  12. }
  13. });
  14. t.start();
  15. }
  16. }
  17. public static void main(String[] args) {
  18. JavaVMStackOOM oom = new JavaVMStackOOM();
  19. oom.StackFowardByThread();
  20. }
  21. }

执行结果

  1. 卡死。。
  2. 预期结果 OOM unable to create new native thread

针对这种情况 虚拟机没有专门的参数去设置虚拟机栈的总大小,但是我们可以通过减少堆区或其他区的大小来给这片内存区域腾出空间。一般很少有人能写出这么蛋疼的代码弄成千上完个线程。

运行时常量池溢出

运行时常量池溢出属于PermGen space (方法区)溢出中的一种情况,同样,为了加速产生oom,我们设置参数 -XX:PermSize=10M -XX:MaxPermSize=10M。通过String.intern()方法我们尝试使常量池被塞满。

  1. public class RunTimeConstantPoolOOM {
  2. public static void main(String[] args) {
  3. List<String> list = new ArrayList<String>();
  4. int i=0;
  5. String s = "abcdefghijklmnopqrstuvwxyz";
  6. while(true){
  7. System.out.println(i);
  8. list.add(String.valueOf(s+i++).intern());
  9. }
  10. }
  11. }

执行结果

  1. 执行好久没结果,有可能是我的虚拟机版本做了优化。
  2. 预期结果Exception in thread main java.lang.OutOfMemoryError:PermGen space
  3. ...

方法区溢出

方法区溢出的另一种更直接的情况就是加载的类太多,导致方法区不够用,这里我们直接借助cglib生成动态代理类来实现。

  1. public class JavaMethodAreaOOM {
  2. public static void main(final String[] args) {
  3. while (true) {
  4. Enhancer enhancer = new Enhancer();
  5. enhancer.setSuperclass(OOMObject.class);
  6. enhancer.setUseCache(false);
  7. enhancer.setCallback(new MethodInterceptor() {
  8. @Override
  9. public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
  10. return methodProxy.invoke(o,args);
  11. }
  12. });
  13. enhancer.create();
  14. }
  15. }
  16. static class OOMObject {
  17. }
  18. }

执行结果

  1. Exception in thread "main"
  2. Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

类似这种情况我们在实战中也经常会遇到,不仅仅是cglib使用的情况 ,还有大量产生jsp文件也会产生P区溢出。正常的PermSpace OOM 我们可以调整P区大小,但针对这种情况我们在使用前就需要先考虑好量级。

直接内存溢出

DirectMemory 容量可以通过 -XX:MaxDirectMemorySize制定,如果不指定则默认和-Xmx指定但一样。

  1. public class DirectMemoryOOM {
  2. private static final int _1MB = 1024 * 1024;
  3. public static void main(String[] args) throws IllegalAccessException {
  4. Field unsafe = Unsafe.class.getDeclaredFields()[0];
  5. unsafe.setAccessible(true);
  6. Unsafe usf = (Unsafe) unsafe.get(null);
  7. while (true) {
  8. usf.allocateMemory(_1MB*100);
  9. }
  10. }
  11. }

执行结果

  1. Exception in thread "main" java.lang.OutOfMemoryError
  2. at sun.misc.Unsafe.allocateMemory(Native Method)
  3. at com.donar.javabasic.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:18)

小结

讲完这些,我们应该明白了虚拟机但内存是如何划分但,各个区域OOM是如何产生但以及如何解决OOM。有了这些基础,相信我们可以更好的去学习Java的垃圾回收机制。