Java内存区域与内存溢出异常(JVM学习系列1)

  相对于C、C++等语言来说,Java语言一个很美好的特性就是自动内存管理机制。C语言等在申请堆内存时,需要malloc内存,用完还有手动进行free操作,若程序员忘记回收内存,那这块内存就只能在进程退出时,由操作系统来释放了。而Java程序员(初级)则基本上不需要对内存分配、回收做过多的关注,完全由Java虚拟机来管理。不过,一旦出现内存泄漏或者溢出,如果不理解JVM管理内存的机制,又如何排除错误、调优系统呢?

1.    运行时区域

1.1程序计数器

  Java程序最终编译成字节码运行在JVM之上,程序计数器可以看做时当前线程执行的字节码的行号指示器。字节码解释器在工作的时候就是通过这个计数器来选择下一条要执行的字节码指令,分支、循环、异常处理等都需要依赖该计数器。

  另外,在多线程的场景下,一个CPU(或者一个核)在一个确定的时刻,只能执行一个线程的一条字节码指令,多线程的实现是由CUP在不同线程间切换来完成的。而CPU在线程间切换所依赖的也是程序计数器(CPU跳来跳去要确定调到某个线程的某一行上,从这一点可以看出,程序计数器是线程私有的,线程间互不影响)。

  注意,在JVM规范中,程序计数器不会发生OOM(就记个数,能用多少内存)。

1.2虚拟机栈

  线程私有,与线程生命周期相同。

  栈描述的是Java执行方法的内存模型。线程是进程创造的(例如服务器的每个请求可以看做是一个线程,举例ThreadLocal),由多个方法间的调用组成,每个方法在执行时会创建一个栈帧,栈帧内存储的是局部变量表,操作数栈,动态链接,方法出口等信息。每个方法从调用直到执行完成,就是一个栈帧入栈到出栈的过程。

  局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。类型:boolean、byte、char、short、int、float、reference(对象起始地址的指针或者句柄)、returnAddress(指向了一条字节码指令的地址)八种。在编译时,每个方法所需的局部变量表大小就固定下来了。(疑问:若在循环体中定义变量,JVM如何取得的局部变量表的大小? 在内层循环中定义变量到底会不会存在重复分配的问题,这涉及到编译器的优化,不过主流编译器(如vs和gcc)这一块优化都比较好,不会反复分配变量。栈中的空间在编译这个代码的时候大小就确定下来了,运行这个方法时空间就已经分配好了,不要想当然的以为声明一次就要分配一次空间,那是c语言,java可以重用这些超出作用域的空间。)

  虚拟机栈这块区域规定了两种异常:StackOverflowError,线程请求的栈深度超过一定量(比如递归层级过多,大概几千(与分配给jvm的内存有关)就报错);OutOfMemoryError,无法申请到足够的内存。

1.3本地方法栈

  本地方法栈与虚拟机方法栈作用相似,区别为虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用的native方法服务。很多虚拟机在实现时已经将二者合二为一。抛错相同。

  Java native 方法:一个Native Method就是一个java调用非java代码的接口。大多数应用场景为java需要与一些底层系统如操作系统、某些硬件交换信息时的情况。

1.4Java堆

  所有线程共享,用于存放所有线程产生的对象实例(还有数组)。

  堆是垃圾收集器管理的主要区域。为了更好的回收或者分配内存,堆可能会被分为多个区域,例如分代收集算法的垃圾回收器会将堆分为新生代和老年代(当然还可以继续细分:Eden、From Survivor、To Survivor等)。但不管如何划分,每个区间存储的内容是不变的,都是对象实例。

  另外,堆在内存中并不是物理连续的,只要逻辑连续即可。当向堆申请内存(实例化对象),而堆中找不到这么大的空间时)会抛出OutOfMemoryError(最新虚拟机都可动态扩展,但扩无可扩时也会抛错)。

1.5方法区

  线程共享,方法区内存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。一些虚拟机实现上,将方法区作为堆上的“永久代”,意味着垃圾回收器可以向管理堆一样来管理这块内存(但本质上,方法区和永久代是不等价的,会产生一些问题,官方已经不推荐这么使用。例如,String.intern()方法在不同的虚拟上会因为该机制而表现不同)。

  当然,方法区也确实有一些“永久”的意思,进入到该区域的数据,例如类信息,基本上就不会被卸载了。但其实也会被卸载,只是卸载的条件相当的苛刻,导致很多垃圾回收器在这部分起到的作用并不大

  当方法区无法满足内存分配要求时,将抛出OutOfMemoryError异常。

1.6运行时常量池

  是上边1.5里讲的方法区中的一部分。Class文件在编译期会生成各种字面量和符号引用,这部分内容将在类加载后,放入到方法区的常量池中存放。另外,并非只有预置入Class文件中的常量池的部分才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中,例如String类的intern()方法。

  运行时常量池属于方法区的一部分,所以当申请不到内存的时候,会抛出OutOfMemoryError异常。

1.7直接内存

  一些native函数库可以直接分配堆外内存,例如NIO,它可以通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。由于直接内存是受机器总内存限制的,当申请不到内存的时候,同样会抛出OutOfMemoryError异常。

2.    对象的创建

2.1对象的创建

  创建对象的几种方式:1)使用new 关键字;2)使用反射的newInstance()方法,newInstance方法通过调用无参的构造函数创建对象;3)clone,调用clone时,jvm会创建一个新的对象,将前面对象的内容全部拷贝进去。用clone方法创建对象并不会调用任何构造函数;4)反序列化,jvm会给我们创建一个单独的对象。在反序列化时,jvm创建对象并不会调用任何构造函数。 我们在着重谈一谈new时都发生了什么。

  当jvm遇到new指令时,第一步要做的是去常量池中找一找,看是否能找到对应类的符号引用,并且检查该符号引用代表的类是否被加载、解析、初始化过(检查类是否被加载)。

  类加载检查通过之后,接下来就是分配内存,对象所需内存大小在类加载完成之后就完全确定了,所以分配对象的工作其实就是把一块确定的内存从Java堆中划出来。

  堆内存是规整的时候——用过的在一边、没用过的在另一边,中间用一个指针标记,内存分配就是指针向没用过的方向挪动一下,这种方式叫做指针碰撞。这个时候若多个线程一起申请内存,就会冲突。对应的解决方法:1)加同步,采用CAS加失败重试策略;2)为每个线程预分配一小块内存(Thread Local Allocation Buffer,TLAB),哪个线程需要内存,就在自己的TLAB上进行分配,而只在创建线程为线程分配TLAB是用同步锁定。

  堆内存不是规整的时候——用过和没用过的乱糟糟的放在一起,内存分配就需要记住哪些地方被分配了,哪些地方还是空闲的,这种分配方式叫做分配列表。在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表。

  对内存是否规整,是由使用的垃圾回收机制是否带有压缩整理功能决定的。

  内存分配完成之后,虚拟机需要设置一下对象的数据:非对象头部分,会被初始化为零值,这个操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用;对象头部分,进行必要的设置,例如:对象类的元数据信息、哈希码、GC分代信息、锁信息等。

  一个新的对象产生了,后续就在java语言层面,按照程序员的想法,执行init函数了。

2.2对象的内存布局

  一个对象在内存中由三部分组成:对象头,实例数据,对齐填充。

  对象头由两部分组成:一部分存储运行时数据:哈希码、GC分代、锁状态等等;另一部分是指向类元数据的指针,说明该对象是由哪个类实例化来的。

  实例数据存放的是对象真正存储的有效信息,也就是程序员自己定义的各种类型字段内容。需要注意的时,为了节省内存,相同类型的字段总是被放在一起存放的,而且子类较窄的变量有可能会插入到父类变量的空隙中。

  由于对象大小必须是8字节的整数倍,所以对齐填充,就是凑整用的,可有可无。

2.3对象的访问定位

  两种定位方式:句柄、直接指针。贴两个图,分别说一下他们的优缺点。

 

  句柄访问,堆内划分出一块内存来作为句柄池,对象引用存储的是句柄地址,句柄中包含了对象的真实地址信息。有点:对象被移动时,无需通知引用这个它的对象,只需要更改句柄池就行了;缺点:增加了一层寻址,会慢一些。

 

  直接指针访问:对象引用的就是真实的地址信息。优点:快,节省一次指针定位时间;缺点:对象被移动时,引用它的对象也要跟着修改。

3.    关于内存溢出

3.1栈溢出

  不断递归,超过栈允许的最大深度时,就可以触发StackOverflowError。看一个栈深度超限引发StackOverflowError的示例,代码及错误信息如下:

 1 public class Stack_StackOverflowError {
 2     private Integer stackLength = 1;
 3 
 4     public void stackLoop() {
 5         stackLength++;
 6         stackLoop();
 7     }
 8 
 9     public static void main(String[] args) {
10         Stack_StackOverflowError a = new Stack_StackOverflowError();
11         try {
12             a.stackLoop();
13         } catch (Throwable e) {
14             System.out.println("stack length: " + a.stackLength);
15             throw e;
16         }
17     }
18 }
Exception in thread "main" stack length: 9651(本人机器64位,12G内存,未对jvm系统做任何参数修改)
java.lang.StackOverflowError
    at java.lang.Number.<init>(Number.java:55)
    at java.lang.Integer.<init>(Integer.java:849)
    at java.lang.Integer.valueOf(Integer.java:832)
    at com.star.ott.scriptsTranslation.api.business.test.Stack_StackOverflowError.stackLoop(Stack_StackOverflowError.java:10)
    at com.star.ott.scriptsTranslation.api.business.test.Stack_StackOverflowError.stackLoop(Stack_StackOverflowError.java:11)
    at com.star.ott.scriptsTranslation.api.business.test.Stack_StackOverflowError.stackLoop(Stack_StackOverflowError.java:11)

 3.2堆溢出

  堆是用来存放对象示例的,只要不断创建对象,并且保证垃圾回收器无法回收这些对象,就能产生堆的OutOfMemoryError异常。看一个不断创建对象引发OutOfMemoryError的示例,代码及错误信息如下:首先将idea中的堆大小限制为20M。

 

import java.util.ArrayList;
import java.util.List;

/**
 * Created by laizy on 2018/7/30.
 */
public class Heap_OutOfMemoryError {
    static class OOMTestObject {
    }

    public static void main(String[] args) {
        //保证创建出来的对象不被回收
        List<Heap_OutOfMemoryError.OOMTestObject> list = new ArrayList<Heap_OutOfMemoryError.OOMTestObject>();
        //不断创建对象
        while (true) {
            list.add(new Heap_OutOfMemoryError.OOMTestObject());
            System.out.println(list.size());
        }
    }
}
540213
540214
540215
540216
540217(向队列中插入这么多对象之后,崩了)
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3210)
    at java.util.Arrays.copyOf(Arrays.java:3181)
    at java.util.ArrayList.grow(ArrayList.java:261)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
    at java.util.ArrayList.add(ArrayList.java:458)
    at com.star.ott.aaa.Heap_OutOfMemoryError.main(Heap_OutOfMemoryError.java:20)

Process finished with exit code 1

 

  另外,在jdk1.8中,String常量池已经从方法区中的运行时常量池分离到堆中了(划重点),也就是说不断的创建String常量,也能够将堆撑爆,代码及错误信息如下:


import java.util.ArrayList;
import java.util.List;

/**
* Created by laizy on 2018/7/31.
*/
// -Xms20m -Xmx20m
public class Heap_StringConstantOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("0");
int i = 1;
try {
while (true) {
list.add(list.get(i - 1) + String.valueOf(i++).intern());
if (list.size() % 100 == 0) {
System.out.println(list.size());
}
}
} catch (Throwable e) {
System.out.print(list.size());
throw e;
}
}
}

1900
2000
2100
2200
2201

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  at java.util.Arrays.copyOf(Arrays.java:3332)
  at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
  at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
  at java.lang.StringBuilder.append(StringBuilder.java:136)
  at com.star.ott.aaa.Heap_StringConstantOOM.main(Heap_StringConstantOOM.java:15)

3.3方法区溢出

  运行时常量池属于方法区的一部分,首先我们通过将常量池撑爆的方式,制造方法区溢出。首先还是限制jvm的参数,设置方法区大小为5m,不限制的话,程序得跑到地老天荒。参照3.2中设置jvm的方式设置方法区大小。在jdk8之前,方法区放到了永久代中,对应参数为:-XX: PermSize=5m -XX:MaxPermSize=5m;在jdk8以后,方法区放到的元数据里,对应参数为:-XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m。代码及错误信息如下:

好吧,让你失望了,我的环境是jdk8,在jdk8中我做不到(捂脸),希望大家指点一下,如何在jdk8中实现常量池的溢出。
另外,在之前的jdk中,要实现常量池的溢出是通过不断创建String来实现的,对,就是上边3.2中的用String.intern()撑爆堆的那种做法。

 

  接下来我们通过CGLib技术,不断创建动态类,将方法区撑爆。代码及异常如下:

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

/**
 * Created by laizy on 2018/7/31.
 */
// -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
public class RunTime_ObjectOOM {

    public static void main(String[] args) {
        int i = 0;
        while (true) {
            i++;
            System.out.println(i);
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method,
                                        Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {
    }
}
328
329
330
331(331次循环之后,方法区崩了)
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:348)
    at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:386)
    at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:219)
    at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
    at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
    at com.star.ott.aaa.RunTime_ObjectOOM.main(RunTime_ObjectOOM.java:28)

 

  最后,栈中的OOM、直接内存OOM并未做验证。

posted @ 2018-07-30 15:28  振宇要低调  阅读(1339)  评论(0编辑  收藏  举报