这是我参与 8 月更文挑战的第 5 天,活动详情查看:8 月更文挑战
前言
在上一篇文章【JVM 入门食用指南】JVM 内存管理中,主要叙述了 JVM 内存管理规范,从线程共享区到线程私有区,对堆、方法区、虚拟机栈分别切入,并利用 HSDB 工具进行实践。本文我们主要堆 JVM 中的对象分配及垃圾回收机制进行了解,加深对 JVM 的理解。
即将学会
- JVM 对象创建
- JVM 内存结构
- 可达性分析算法
- 垃圾回收算法
对象创建
对象创建过程
对象的创建过程主要涉及以下流程
对象创建过程
-
类加载
-
检查加载 执行一个类,需要加载到 JVM 运行时数据区中的方法区中,对该类进行一些检查。
-
分配内存
- 如何划分内存
- 如何解决并发安全
-
初始化
-
设置
-
对象初始化
检查加载
检查类是否被加载、解析和初始化过。
符号引用
常量池中,对对象、方法、字段的引用时,当我们不知道其地址时,利用符号去表示。 这个符号足以唯一的识别一个类。 直接引用 (真实的地址) 比如 A 常量池中引用 B,这个时候 B 还未加载进来,只能利用符号去进行指定。而检查加载过程中,JVM 会将符号引用转换为直接引用(检查无误后)。
内存分配
为新对象分配内存,这其中涉及到内存划分以及划分区域中并发引起的问题解决。
内存划分
- 指针碰撞
内存分配
- 通过指针的指向 对对象内存进行划分,要求堆中内存是规整的,分配过的在一边,未分配的在另一边。以指针作为导向,进行内存分配,通过指针偏移一段与对象大小相等的内存。
- 这种分配方案较快,但是对堆空间要求较高。如果堆中有内存碎片,很容易指向内存区域块的内存空间不够。
- 空闲列表
空闲列表
- 如果堆中内存不规整,有内存碎片的。JVM 就需要维护列表,记录堆中内存区是否可用,在分配过程中找到一块足以容纳该对象实例的内存空间。分配后更新该表。
并发问题
因为对象创建是比较频繁的问题,可能正在分配对象的过程中,但是指针和表未来得及更新,就会涉及到并发的一些问题。
- CAS 机制 (Compare And Swap)
JVM 采用 CAS 和失败重试的方式保证更新操作的原子性,CAS 机制中,保存了三个基本操作数,内存地址 V,旧的预期值 A,要修改的新值 B。比较,任意线程获取 V 中的值存储在 A 中。当其它线程在获取 V 中值并先更新完成后,此时 V 中的值已改变,当前线程在修改值前获取 V 中的值, 发现此时 V 中值与存储的旧的预期值 A 不一。重新获取 M 中的值存储在 A 中(自旋 循环不断先查值 进行操作 对比,直到成功后才跳出循环),再进行对 V 中的值对比,发现其 V 中的值与 A 的值一至,swap 将 B 的值更新 。CAS 避免了锁操作,但是,这依旧不是 JVM 默认的并发解决方案。CAS 对 CPU 开销还是比较大的,在并发过程中,可能会有许多线程反复尝试更新某一个变量,却又一直更新不成功,一直循环,给 CPU 带来压力
- 分配缓冲 TLAB (Thread Local Allocation Buffer)
为每一个线程分配线程缓冲。既每个线程在堆中预先分配一小块私有内存每一个线程去分配对象的时候,都在不同的缓冲区,相当于单线程,没有并发的问题。
内存空间初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不是构造方法中)(如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置(对象头设置)
JVM要对对象进行必要的设置,如这个对象是哪个的实例,对象的Hash码,对象GC分代年龄
对象填充
对象大小需要为8字节的整数。不足的时候进行填充
对象初始化
依据构造方法执行构造方法。虚拟机视角:一个新的对象已经产生了。Java程序视角:对象的创建刚刚开始,所有的字段都还停留在内存空间初始化的0值。执行new指令后会接着把对象按照开发人员的设计进行构造方法初始化,这样真正可用的对象才算完全产生。
对象内存分布
对象访问定位
上面已经把对象的创建流程过了,我们把对象创建出来了,是为了使用对象。因此,需要了解对象在内存中的分布,对象的结构。但是,这之前,我们还需要找到这个对象。
Java程序通过栈上的reference数据来操作堆上的具体对象。主流的访问方式有以下两种。
- 句柄 引用中存储的是句柄池中的句柄地址。使用句柄的话,将会在Java堆中,划分出一块内存作为句柄池。句柄中包含了对象实例数据与类型数据、各自的具体地址信息。使用句柄可以在引用中存储稳定的句柄地址,在对象被移动(GC),只需要改变句柄实例指针,而引用本身不需要修改。类似我们做数据加载时的封装。
- 直接指针 引用中存储的直接是对象地址。直接指针访问的方式的好处速度更快,节省了一次指针定位的时间开销,又因为对象的访问在Java中非常频繁,这类开销由于使用量级的开销也是可观的执行成本。在
hotSpot
中,是使用直接指针访问方式进行对象访问的。
垃圾回收 自动化的垃圾回收
手动垃圾管理可能会忘记 或者受到多线程的影响
对象存活判断
- 引用计数算法 对象中添加引用计数器,每当有一个地方进行引用,计数器+1,引用失效,计数器-1。
public class MutualReference {
private Object instance = null;
private byte[] bigByte = new byte[10 * 1024 * 1024];
public static void main(String[] args) {
MutualReference objectA = new MutualReference();
MutualReference objectB = new MutualReference();
objectA.instance = objectB;
objectB.instance = objectA;
objectA = null;
objectB = null;
System.gc();
}
}
复制代码
但是这种会出现相互引用的情况(如上述代码所示),需要额外处理这种情况。因为在相互引用的情况下,如果只有单纯的对象与对象间有引用,是没有用的,我们需要去执行,在方法中执行。 而针对这种循环引用,额外启动一个线程处理。需要额外启动线程,因此gc过程效率不高。
-
根可达性分析算法 通过一系列的GC roots(roots = RootSet)对象作为起始点,当一个对象到GC toots没有任何引用链相连时,则该对象是不可达的。
-
GC roots 对象
- ✨虚拟机栈(栈帧中的本地变量表)中引用的对象:各个线程调用方法堆栈中使用到的参数,局部变量、临时变量
- ✨方法区中类静态属性引用的对象:Java类的引用类型静态变量。
- ✨方法区中常量引用的对象: 字符串常量池的引用
- ✨本地方法栈中JNI Native 引用的对象。
- JVM的内部引用:class对象,异常对象,系统类加载器
- 所有被同步锁持有的对象
- JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
- JVM实现中的“临时性”对象,跨代引用的对象
-
-
Class根对象回收 条件比较苛刻,必须同时满足以下的条件(可以 不一定是一定)
-
该类所有的实例都已经被回收,既堆中不存在该类的任何实例
-
加载该类的
classloader
被回收 -
创键类的时候对应的
java.lang.Class
对象没有在任何地方被引用。并且无法在任何地方通过反射访问该类的方法 -
参数控制
-
- 这个参数需要关闭
-
finalize 就算判定了对象是不可达了,已经是垃圾了,但是,也不一定一定要回收,真正宣告对象的死亡,需要进行两次标记,一次没有找到GCRoots的引用链,被标记一次,然后进行筛选(),在finalize进行拯救 。可以通过覆盖finalize方法,在里面把引用链接上。不过finalize优先级较低,还需要利用线程休眠才能实现。而且该方法只能执行一次
//当垃圾收集器确定不再有对对象的引用时,由垃圾收集器在对象上调用。 子类覆盖finalize方法以处理系统资源或执行其他处理
protected void finalize() throws Throwable { }
//--------------------------------------------------
public class FinalizeDemo {
public static FinalizeDemo instance = null;
public void isAlice(){
System.out.println("still alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method is executed");
FinalizeDemo.instance = this;
}
public static void main(String[] args) throws InterruptedException {
instance = new FinalizeDemo();
//第一次GC
instance = null;
System.gc();
Thread.sleep(1000);
if (instance != null){
instance.isAlice();
}else {
System.out.println("is dead");
}
instance = null;
//二次GC
System.gc();
Thread.sleep(1000);
if (instance != null){
instance.isAlice();
}else {
System.out.println("is dead");
}
}
}
//------------------------------
//上述代码执行结果
//finalize method is executed
//still alive
//is dead
//当我们把线程休眠注释掉后,发现finalize没有生效
复制代码
没什么开发场景可以使用 大家当什么没发生就好 可以通过try catch finally
在finally中把引用链续上
对象引用
- 强引用 =
任何情况下,只要有强引用关联,垃圾回收期就永远不会回收掉引用的对象。 - 软引用 SoftReference 一些有用但是并非必须,用软引用关联的对象,系统会在将要发生OOM之前,将这些对象回收。
public static void main(String[] args) {
// VM option 相关参数设置 -Xms10m -Xmx10m -XX:+PrintGC 设置说明 堆初始值 和 最大值设置 为10M
User user = new User(1,"me");//强引用
SoftReference<User> userSoftReference = new SoftReference<User>(user);
//此时 new User(1,"me")这个对象有两个引用 一个强引用 一个软引用
// 我们这个时候需要去掉强引用 确保该实例对象只有软引用
user = null;
System.out.println("soft reference output");
System.out.println(userSoftReference.get());
//垃圾回收后存活
System.gc();
System.out.println("after gc");
System.out.println(userSoftReference.get());
List<byte[]> bytes = new LinkedList<>();
try {
for (int i = 0; i < 100; i++) {
System.out.println("-------- soft reference " + userSoftReference.get());
bytes.add(new byte[1024 * 1024]);
}
}catch (Throwable e){
System.out.println("exception: "+ e + " after Exception: " + userSoftReference.get());
}
}
--------------------------------
日志输出
--------------------------------
soft reference output
com.example.jvmLearn.entity.User@75b84c92
[GC (System.gc()) 1645K->660K(9728K), 0.0008071 secs]
[Full GC (System.gc()) 660K->601K(9728K), 0.0043605 secs]
after gc
com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
-------- soft reference com.example.jvmLearn.entity.User@75b84c92
[GC (Allocation Failure) -- 7829K->7829K(9728K), 0.0006787 secs]
[Full GC (Ergonomics) 7829K->7750K(9728K), 0.0055689 secs]
[GC (Allocation Failure) -- 7750K->7750K(9728K), 0.0005041 secs]
[Full GC (Allocation Failure) 7750K->7732K(9728K), 0.0050856 secs]
exception: java.lang.OutOfMemoryError: Java heap space after Exception: null
复制代码
日志分析:我们可以看到 当我们对堆空间设置初始10M,最大10M并打印GC信息时,GC后软引用并没有被回收,当将要发生OOM之前,这些对象就会被回收(如果这次回收后依旧没有足够的堆内存空间,才会抛出OOM) 使用场景 :处理图片,如果将所有图片读入内存,可以很快打开图片,但是内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。但是如果每次处理图片都需要从磁盘读取到内存再打开,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘。代价教大,这种情况下,可以使用软引用构建缓存。快OOM了,将引用对象回收,保证程序的正常运行,缓存没了也没事,这个时候重新从数据库或者网络读。
- 弱引用 WeakReference,用弱引用关联的对象 一些有用,但是非必需,程度比软引用更低。弱引用关联对象,只能生存到下一次垃圾回收之前,GC时,无论内存够不够,都会被回收。
User user = new User(1,"me");
WeakReference<User> userWeakReference = new WeakReference<>(user);
user = null;//原理同上
System.out.println("weak reference :"+userWeakReference.get());
System.gc();
System.out.println("after gc");
System.out.println(userWeakReference.get());
-----------------------
weak reference :com.example.jvmLearn.entity.User@75b84c92
[GC (System.gc()) 1766K->632K(19968K), 0.0031245 secs]
[Full GC (System.gc()) 632K->580K(19968K), 0.0045743 secs]
after gc
null
复制代码
使用场景比软引用广一些,软引用和弱引用可以在内存资源紧张的情况下创建不是很重要的数据缓存,系统内存不足时,缓存的内容可以被释放。weakHashMap、threadLocal。
- 虚引用 PhantomReference 随时会被JVM回收。可以监控回收器是否正常工作。
对象分配策略
对象分配原则
几乎所有对象在堆中分配
-
对象优先在eden分配 多数情况下,对象在Eden区分配。利用TLAB
-
空间分配担保 在发生GC时,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象空间,如果条件成立,GC是安全的,不成立,则虚拟机可以根据HandlePromotionFailure值是否允许担保失败。允许的话,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行GC,虽然有风险,担保失败会进行Full GC(针对老年代 新生代 元空间的全局访问GC),如果小于,或者HandlePromotionFailure设置位不允许冒险。那这时也要改为进行一次Full GC。 就是没有必要先进行一次老年代回收,因为每次回收平均值可以大于这次晋升空间
-
大对象直接进入老年代
- 大对象:需要大量连续内存空间的Java对象,长字符串、大数组等。大对象容易在分配空间时,容易导致内存还有不少空间、为了获取足够的连续空间进行空间划分,提前触发GC。且复制对象会产生高额的内存复制开销。而直接在老年代分配,可以避免大对象在Eden区及from、to区来回复制而产生高额开销
-
长期存活的对象进入老年代 JVM通过分代收集来管理内存,内存回收时需要确定存活对象在什么代中。因此,给对象定义一个Age,存储在对象头中,进行判断。对象在GC后,Age+1,如果Age到达15(默认 可以通过-XX:MaxTenuringThreshold调整 只能往小调 在mark word中 age只有4位二进制),对象到老年代.
-
动态对象年龄判定 JVM 可以通过-XX:MaxTenuringThreshold 参数控制晋升年龄,每次经过一次GC,年龄+1,达到最大值进入Old区。但是设定固定的值使其作为晋升条件,会出现一些问题,设置过大的话,对象一直停留在新生代,知道from、to区溢出,不得不晋升到old区、晋升过慢。如果过小的话,过早晋升对象不能在新生代充分回收,大量对象短时间内晋升到Old区,老年区空间迅速扩大,频繁GC。分代回收失去意义。 为了解决这种问题,适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄达到最大值晋升至老年代。如果from、to区相同年龄所有对象占据from、to区空间一半,年龄大于或等于该年龄的对象直接进入老年代,无需到达最大值。
虚拟机的优化技术
- 逃逸分析 + 触发JIT 解释执行与JIT。 Java程序在运行的时候,主要执行字节码指令,一般这些指令会按照顺序解释执行。但是哪些被频繁调用的代码,比如调用次数很高或者在for循环中、循环次数过多的代码,效率是较低的。这种代码称之为热点代码。 为了提高执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,完成编译期,称之为即时编译 代替解释执行
而判定条件在服务端模式一般是10000次,客户端一般是1500次。我们用的一般是服务端。可以通过java -version来进行查看 。我们可以看到Server VM这个字段 是服务端模式
逃逸分析:分析对象的动态作用域,当一个对象在方法中定义后,可能被外部方法引用。 比如 调用参数传递到其它方法中,称之为方法逃逸、被外部线程访问:赋值给其它线程中访问的变量,这个称之为线程逃逸。 如果确定一个对象不会逃逸出线程外,让对象在栈上分配内存可以提高JVM效率。
我们可以通过以下代码观察这种情况
public class EscapeAnalysisDemo {
//VM 参数设置 -XX:+PrintGC
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 500000000; i++) {
allocate();
}
long time = System.currentTimeMillis() - start;
System.out.println(time);
}
private static void allocate() {
User user = new User(1,"me");
}
------------------------------------
结果输出 5
调整VM参数 参数设置为 -XX:+PrintGC -XX:-DoEscapeAnalysis
-XX:-DoEscapeAnalysis 意思是关闭对象逃逸
我们可以看到输出结果
[GC (Allocation Failure) 64512K->784K(247296K), 0.0006289 secs]
[GC (Allocation Failure) 65296K->704K(247296K), 0.0013675 secs]
[GC (Allocation Failure) 65216K->752K(247296K), 0.0006076 secs]
[GC (Allocation Failure) 65264K->776K(311808K), 0.0007502 secs]
[GC (Allocation Failure) 129800K->792K(311808K), 0.0007722 secs]
[GC (Allocation Failure) 129816K->776K(431104K), 0.0006411 secs]
[GC (Allocation Failure) 258824K->644K(431104K), 0.0011366 secs]
[GC (Allocation Failure) 258692K->644K(688640K), 0.0003796 secs]
[GC (Allocation Failure) 516228K->644K(689152K), 0.0009766 secs]
[GC (Allocation Failure) 516228K->644K(998912K), 0.0006369 secs]
[GC (Allocation Failure) 825988K->644K(998912K), 0.0012758 secs]
[GC (Allocation Failure) 825988K->644K(1494528K), 0.0004210 secs]
[GC (Allocation Failure) 1321604K->1068K(1494528K), 0.0010376 secs]
[GC (Allocation Failure) 1322028K->1032K(1431040K), 0.0005847 secs]
[GC (Allocation Failure) 1259528K->1032K(1372672K), 0.0007110 secs]
[GC (Allocation Failure) 1200136K->1032K(1314816K), 0.0007293 secs]
[GC (Allocation Failure) 1143304K->1032K(1262080K), 0.0010164 secs]
[GC (Allocation Failure) 1089544K->1032K(1209856K), 0.0005979 secs]
1723
}
复制代码
代码分析,我们可以比较直观的看到,allocate方法执行很多次(触发JIT),我们没有必要利用解释器解释,JVM在这种情况下进行了一些优化。这个时候进行逃逸分析,发现对象无法逃出方法、不会被其它线程调用,可以将这个对象以基本数据类型的形式转换到栈中局部变量表。这个时候,我们也可以看到,如果这个时候没有逃逸发生,只需要5ms就可以执行完成,而禁掉逃逸分析后,对象分配在堆中,JVM中发生了大量的GC,并且需要1723ms才可以完成。
- 本地线程分配缓冲 (TLAB)上面有提
GC
什么是GC
垃圾回收是一个动作
新生代(Eden from to 大概比例8:1:1) 老年代
栈:栈中生命周期跟随线程,不需要关注
堆:堆中对象是垃圾回收的重点
GC种类
- 新生代回收(Minor GC) 只是进行新生代的回收
- 老年代回收(Major GC) 只是进行老年代的回收 定义比较混乱 依据自己场景定义
- 整堆回收(Full GC) 收集整个Java堆和方法区(方法区是线程共享的 )
垃圾回收算法 怎么回收
-
复制算法 (适合新生代) 将可用的内存按照容量划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面(依次复制)。然后再把已经使用过的内存空间一次清理,可以使每次都是堆整半个区域进行内存回收,内存分配时也不用考虑内存碎片等情况,是按顺序分配内存的。不过这种算法空间利用率低。
-
实现简单 运行高效
- 大部分对象的生命周期是比较短暂的,每次复制后都只剩下很少的对象需要复制,效率比较高,而需要清理的一半可以一次性清理。
-
没有内存碎片
-
空间利用率只有一半
-
-
Appel式回收 一种更加优化复制回收分代策略。在这之中,分配一块较大的Eden区和两块较小的From To空间。因为大部分对象周期较短,而对象分配优先在Eden区域,因此,划分更多的区域给Eden区,两块较小的from、to区、回收时,每次使用Eden和其中一块的S区,当回收时,将Eden和S区中还存活的对象一次性复制到另一块S区,最后清理Eden区和刚才用过的S区。HotSpot中,Eden和S区的大小比例是8:1.当S区(Survivor)空间不够时,需要依赖其它内存进行分配担保。
上述两种算法比较适合新生代
-
标记-清除算法 Mark—Sweep 该算法较适合老年代 算法分为 标记 和 清除 两个阶段,首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收被标记的对象,要扫描两次。 回收效率低,如果大部分对象生命较短,回收效率低,需要大量标记对象和回收对象,对比复制回收效率低。 且碎片太多容易产生大量内存碎片,空间碎片太多在后续大对象分配过程中,无法找到连续的内存,而触发GC。
- 标记整理算法 标记出所有需要回收的对象,标记完成后,让存活对象向一端移动,然后直接清理端边界以外的内存(一次性整体清理),标记整理算法虽然没有内存碎片,不过效率比较低。