对于C/C++而言,在语法层面上可以随意控制程序中的一个对象的生命周期,即能够自由宣布对象诞生,又能随时宣判对象死亡,这种方式称之为手动管理内存。手动管理内存非常自由与灵活,但它也具备一定的复杂性,在某些情况下往往会直接或间接的导致程序运行崩溃,一旦出现这种问题,开发者将会把大把的时间浪费在定位问题上,所以手动管理内存是一把明显的双刃剑。
手动管理内存究竟会发生那些意外而终止程序呢?
- 内存泄漏:当打算释放一个链表所引用的空间时,却错误的释放了链表的第一个元素,而剩下的元素尽管已经不再被引用,但却离开了整个程序的控制范围,那么链表中的元素所占用的内存空间永远无法释放,这就是内存泄漏。内存泄漏并不会立即引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至消耗所有内存,最终程序崩溃。
- 内存溢出:当程序运行所需内存空间超出分配空间则抛出OutOfMemoryError异常,这就是内存溢出。原因可能是有大量无用内存没有得到释放,消耗可用内存,导致没办法给新的对象或线程分配空间,导致程序崩溃。
相比较而言,JVM的自动管理内存机制,无需开发人员手动参与内存分配和回收,降低了内存泄漏和内存溢出的风险,更重要的是自动内存管理机制让开发人员有更多的经理去关注自身的业务。但是过度依赖自动管理内存,就会弱化开发者在程序出现内存溢出是定位问题和解决问题的能力。所以了解JVM的自动内存管理,我们才能在遇见OOM时,快速根据错误异常日志丁文和解决问题。
JVM内存分配原理
Java堆区是分配/存储对象实例的主要区域。JVM中包含三种引用类型,
类类型(class type)、数组类型(array type)和接口类型(interface type),这些引用类型的值则分别由类实例、数组实例以及实现了某个接口的派生类实例负责动态创建,那么JVM中究竟是如何为这些类型创建对应的对象实例呢?
当语法层面使用new关键字创建一个Java对象时,JVM首先会检查这个new指令的参数能否在常量池中定位到一个类的符号引用,然后检查与这个符号引用相对应的类是否已经成功经历过加载、解析和初始化等步骤,当类完成装载步骤之后,就可以确定出创建对象实例时所需要的内存空间大小,接下来JVM将会对其进行内存分配,以存储所生成的对象实例。
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是非线程安全的,所以务必需要保证数据操作的原子性。基于线程安全的考虑,如果一个类在分配内存之前已经成功完成类装载步骤之后,JVM就会优先选择在TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间,TLAB在Java堆区中是一块线程私有区域,它包含在Eden空间内,除了可以避免一系列的非线程安全问题外,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存,如果当在Eden空间中也无法分配内存时,JVM就会执行Minor GC,直至最终可以在Eden空间中分配内存为止(如果是大对象则直接在老年代中分配)。
参数设置:
- -XX:TLABWasteTargetPercent:设置TLAB空间所占用Eden空间的百分比大小。
- -XX:UseTLAB:设置是否开启TLAB空间。
当为对象成功分配好所需的内存空间后,JVM接下来要做的事情就是初始化对象实例。JVM首先会对分配后的内存空间进行零值初始化,这一步操作确保了对象的实例字段在Java代码中可以不用赋初始值就能够直接使用,程序能够访问到这些字段的数据类型所对应的零值。对分配后的内存空间进行零值初始化后,JVM就会初始化对象头和实例数据(在HotSpot中,内存空间中所存储的对象信息主要包含这两个部分),最后将对象引用入栈后再更新PC寄存器中的字节码指令地址。经过这一系列的操作步骤之后,一个Java对象实例才算是真正创建成功。
逃逸分析与栈上分配
Java堆区已经不是对象内存分配的唯一选择,如果希望降低GC的回收平率和提升GC的回收效率,那么则可以使用堆外存储技术。目前最常见的堆外存储技术就是利用逃逸分析技术筛选出未发生逃逸的对象,然后避开堆区而直接选择在栈帧中分配内存空间。
逃逸分析(Escape Analysis)是JVM在执行性能优化之前的一种分析技术,它的具体目标就是分析出对象的作用域。简单来说,当一个对象被定义在方法体内部之后,它的受访权限仅限于方法体内,一旦其引用被外部成员引用后,这个对象就因此发生了逃逸,反之如果定义在方法体内的对象并没有被任何的外部成员引用时,JVM就会为其在栈帧中分配内存空间。
由于对象直接在栈上分配内存,因此GC就无需执行垃圾回收。栈帧会伴随着方法的调用而创建,伴随着方法的执行结束而销毁,由此可见,栈上分配的对象所占用的内存空间将会随着栈帧的出栈而释放。
参数设置:
- 在JDK 6u23版本之后,HotSpot中默认就已经开启了逃逸分析,如果使用的是较早的版本,开发人员则可以通过选项“-XX:+DoEscapeAnalysis”显式开启逃逸分析。
- -XX:+PrintEscapeAnalysis:查看逃逸分析的筛选结果。
Java堆区内存分配与GC回收
- JVM需要创建新的实例化对象(关键字new、对象克隆、反射实例化),就要进行内存空间的开辟,那么就要申请新的内存空间;
- 新对象要申请的对象空间默认都是在伊甸园(Eden,新生)进行开辟,所以首先判断是否有空余的内存空间,如果有,则直接在伊甸园(Eden)开辟新的堆内存空间,此时不会有GC执行。
- 如果新对象无法在Eden孔家你申请出新的空间,那么就表示Eden内存空间不足,那么Eden中就需要进行内存回收(Minor GC),当回收完成后要继续判断该空间是否还有空余的空间,如果有,则开辟新空间保存新对象;
- 如果此时Eden即使执行了Minor GC之后,发现依然没有可以被回收的对象(即没有多余的空间),那么此时将继续判断Survivor(S0,S1,存活区与Eden的空间大小比例,S0:S1:Eden = 1:1:8),如果存活区有空闲空间,则将Eden的部分活跃对象直接存到Survivor,从而腾出空间供新对象使用。
- 如果此时Survivor也是空间不足,则继续向老年代(Turned)进行内存空间的申请,首先判断Turned是否有空间剩余,如果有则将Survivor中的活跃对象保存在Turned中,而后Survivor和Eden得到空间释放,此时向Eden中申请空间成功。
- 如果Turned区中也空间不足,那么此时会执行Full GC(完全GC,Major GC),进行Turned空间释放,如果释放成功,则Eden能为新对象开辟空间,否则抛出OOM(OutOfMemoryError)。

