前言:学习本章前先阅读之前我写的关于JVM系列的前两篇文章:
JVM类加载机制深入浅出分析--JVM系列(1)
JVM内存模型--JVM系列(2)
一.聊聊对象创建主要流程
1.类加载检查:
当JVM遇到一条创建对象的指令时(例如:利用反射创建对象、调用new语句创建对象、调用对象的clone方法创建对象、利用反序列法手段创建对象),首先会去检查该指令的参数能否在常量池中定位到该对象对应的类的符号引用,并且会去检查这个符号引用代表的类是否已经被加载、验证、解析和初始化。假如没有加载则会先执行相应的类加载过程。
2.给对象分配内存:
通过类加载检查后,对象的所需要的内存的大小就确定了,JVM将会给新的对象分配内存,给对象分配内存空间的过程就是在Java堆中划分一块对象所需大小的空间给对象。
这里就会有小伙伴A问怎么划分内存的呢?这里就涉及到内存划分的两种方式:
(1)指针碰撞法(Bump the Pointer):默认使用这种方法,该方法适合Java堆中内存规整的情况,也就是已经被用的内存都在一边,而还没有用的内存都在另一边,指针就放在其中间作为这两个分区的分界指示器(相当于工具人),那么当下一次要分配内存空间时,这个指针(工具指针)就会被无情的挪动一段距离(这个距离大小等于对象所需内存的大小)。
这里又会有小伙伴B问,假如遇到并发的场景,线程1利用指针碰撞法给一个对象分配内存完毕但指针还没开始偏移,这时线程2进来给另一个对象分配内存正好用到了还未来得及偏移的指针咋个办?
针对于这种并发问题也有两种解决方法:
- CAS(Compare And Swap):JVM应用 CAS + 失败重试的机制对分配内存空间的动作做同步,来确保更新动作的原子性。
- 本地线程分配缓冲(TLAB):给每个线程在Java堆中预先分配一小块内存空间,而内存分配的动作就可以在每个线程自己的内存空间中进行,设置JVM使用TLAB(默认会开启)可以通过设置参数-XX:+UseTLAB,指定TLAB大小使用参数-XX:TLABSize。
(2)空闲列表(Free List):这种方法可以说是指针碰撞法的补充,针对于Java堆内存不规整、有内存碎片的情况(已用内存和未用的内存相互混杂),对于这种情况显然指针碰撞没办法处理了,故此时空闲列表(工具表)就来了,空闲表由JVM负责维护,主要用于记录了堆中可用的内存地址,当要给对象分配内存的时候就会从空闲列表中找到一块足够大的空间给对象实例,同时更新空闲列表上的记录。
3.内存空间初始化:
内存分配完毕后,JVM需要将分配到的内存空间都初始化为零值(这里的初始化是设置最原始的默认值,而非指定的初始值,同时这个初始化不包括对象头),如果使用的是TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,程序能访问到这些字段数据类型对应的默认值。
4.设置对象头:
既然说到了对象头,那就有必要聊一聊一个对象在内存中的结构了,在HotSpot虚拟机中,一个对象在内存中的存储结构主要分为3个区域,如下图所示:
Object Header(对象头):对象头又包含三个部分 ——
- Mark Word(标记字段)(这里是64位占8个字节,如果是32位对象头就占4个字节)—— 讲人话就是自身运行时数据:比如锁状态,哈希值,GC分代年龄,当前线程指针,偏向锁的时间戳(Epoch)。细节点:图中可以看出GC分代年龄为4bit,这也是为什么minor GC(young GC)默认为15岁的原因。
- Klass Pointer(类型指针)(为了节省空间64位的JVM默认开启指针压缩占4个字节,不开启压缩占8个字节)—— 就是指向方法区中的类元数据的指针,这样该对象可随时知道自己是哪个Class的实例。
- 数组长度(注意点:这部分只有数组对象才有,同时在64位JVM中原本也是占8个字节,但是默认会开启指针压缩,所以只会占4个字节)
Object Body(对象体):对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型。
Padding(字节对齐):在64位操作系统中保证对象是8个字节的整数倍,对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式,字节对齐深层次原因是:各个硬件平台对存储空间的处理上有很大的不同,一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的CPU在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。
5.执行<init>方法:
执行<init>方法,就是对象按照开发者的设置的初始值进行初始化,也就是为属性赋值,然后执行构造方法。
二.聊聊对象大小与指针压缩
怎么查看对象大小呢?博主这里推荐一款查看对象大小的神器 —— jol-core包
引入依赖:
<!-- 对象大小可以用jol-core包查看-->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
测试代码:
public class JOLTest {
public static void main(String[] args) {
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
ClassLayout layout2 = ClassLayout.parseInstance(new Object());
System.out.println(layout2.toPrintable());
ClassLayout layout3 = ClassLayout.parseInstance(new User());
System.out.println(layout3.toPrintable());
}
// -XX:+UseCompressedOops 默认开启的压缩所有指针
// -XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
// Oops : Ordinary Object Pointers
public static class User {
//8个字节 Mark Word(标记字段)
//4个字节 Klass Pointer(类型指针) 如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8个字节
byte b; //1个字节
Object o; //4个字节 如果关闭压缩-XX:-UseCompressedOops,则占用8个字节
int id; //4个字节
String name; //4个字节 如果关闭压缩-XX:-UseCompressedOops,则占用8个字节
}
}
打印结果:
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) // Mark Word 标记字段
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // Mark Word 标记字段
8 4 (object header) 6d 01 00 20 (01101101 00000001 00000000 00100000) (536871277) // Klass Pointer 类型指针
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // 数组长度
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) // Mark Word 标记字段
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) // Mark Word 标记字段
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) // Klass Pointer 类型指针
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
com.cggeeker.jvm.JOLTest$User object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) a1 cc 00 20 (10100001 11001100 00000000 00100000) (536923297)
12 4 int User.id 0
16 1 byte User.b 0
17 3 (alignment/padding gap)
20 4 java.lang.Object User.o null
24 4 java.lang.String User.name null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
Process finished with exit code 0
关于指针压缩的 jvm配置参数,启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops ; -XX:+PrintFlagsFinal,验证UseCompressedOops的值,查看是否开启指针压缩。
什么是java对象的指针压缩?
就是用4个字节表示32G的内存空间,大白话就是开源节流,用最少的人干最多的事。
比如4个字节,32位,可以表示2^32个地址,那么由于CPU寻址的最小单位是byte,所以最多也就只能表示4G的内存空间(2^32 byte = 4GB)。那么想要表示更大的内存空间就只能对位数做增量使用64位的指针来表示,可是真的采用64位的指针来表示内存空间确实能够表示更大的内存空间,但也同时带来一些问题:
(1)增加了GC开销:64位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少,从而加快了GC的发生,更频繁的进行GC。
(2)降低CPU缓存命中率:64位对象引用增大了,CPU能缓存的oop(ordinary object pointer 对象指针)将会更少,从而降低了CPU缓存的效率。
为解决以上问题,JVM就做了折中处理,引入了"指针压缩"这一概念,JVM将堆内存进行了块划分,以8个字节为最小单位进行划分,不再是真实的操作系统内存地址,而是Java进行8Byte映射之后的地址,所以也相对于操作系统的指针进行的8倍的扩容。所以还是使用32位的指针但是此时表示的内存空间却达到了32G(4G * 8)。
为什么要进行指针压缩?
- 在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。
- 为了减少64位平台下内存的消耗,启用指针压缩功能。
- 在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)。
- 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
- 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现(1)的问题,所以堆内存不要大于32G为好
注意:32G是个近似值,这个临界值跟JVM和平台有关,当我们线上真正启动服务的时候直接设置 -Xmx=32GB 的时候很可能导致 CompressedOop 失效,那我们怎么确定当前环境下最大内存设置多大才且最大限度的使用内存才能启动 CompressedOop 呢?我们可以通过增加JVM参数 -XX:+PrintFlagsFinal,验证UseCompressedOops的值,从而得知,到底是不是真的开启了压缩指针,还是压缩指针失效!
三.聊聊对象内存分配(仅对HotSpot讨论)
对象内存分配流程图:
1.对象在栈上分配?
按照正常的套路对象不都是在堆上进行分配的吗?其实这句话说对了一半,一个对象除了可以在堆上分配在一定条件下还可以在栈(线程栈)上分配,如果非得给个原因:
假如在JVM中对象都是在堆上分配,那么当对象没有在引用链上就变成了"游离对象",面临被GC回收的命运,问题是很多垃圾对象都是“临时工”,用了就会被直接回收掉,这种“临时工”对象一多起来,那还得了,很可能造成GC一直回收垃圾对象,这样就会影响程序性能。所以JVM设计者为了减少临时对象在堆内分配的数量,从而减少GC次数提升程序性能,JVM通过逃逸分析确定一个对象会不会被外部访问,如果不会被外部访问(也就是不会逃逸)就可以将该对象在栈上分配内存,这样这个对象所占用的内存空间就可以随栈帧出栈而销毁,减轻了垃圾回收的压力。
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如被当成返回值给一个变量赋值或者作为调用参数传递到其他地方中。
public User method1(){
User user = new User();
user.setId(58);
user.setName("CGgeeker");
return user;
}
public void method2(){
User user = new User();
user.setId(58);
user.setName("CGgeeker");
}
从上面的代码中可以看出,method1方法中创建的user对象被当作返回值了,所以这个user对象的作用域范围不能确定(有可能是个“逃逸对象”);而method2方法中的user对象可以确定是个“临时工”,当这个方法结束后这个user对象就成为无效对象,所以对于这样的对象就可以将其在栈上分配,让其在方法结束时随栈内存一起被回收。
自JDK7之后JVM默认开启了逃逸分析来优化对象内存分配位置,具体是通过标量替换优先在栈上分配,开启逃逸分析加入参数 -XX:+DoEscapeAnalysis;关闭逃逸分析使用参数 -XX:-DoEscapeAnalysis 。
标量替换:通过逃逸分析判断出某个对象不会被外部访问,并且这个对象可进一步进行分解时(一般是聚合量分解成标量),JVM不会创建这个对象,而是将这个方法中使用到的这个对象的成员变量分解出来,那么这个对象就被分解出来的成员变量代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样既节省了一部分创建对象的空间又不会因为没有一大块连续空间导致对象内存不够分配。变量替换可谓是“以小换大”。JDK7之后默认开启了变量替换,也可以通过设置参数关闭变量替换:-XX:+EliminateAllocations 。
注意标量和聚合量这两个概念,标量是指不可以进一步分解的量,比如Java的基本数据类型就是标量;聚合量是指可以被进一步分解的量,比如对象。
下面我将用代码分别验证两个观点:
- 验证不开启逃逸分析和变量替换将会产生大量GC(采用控制变量法进行测试);
- 验证栈上分配的方式只对非逃逸对象有效;
开始证明第一个观点:
(1)设置参数关闭逃逸分析和变量替换:-XX:-DoEscapeAnalysis -XX:-EliminateAllocations
public class EscapeAnalysisTest {
public static void main(String[] args) {
// 循环调用1亿次userMethod方法
for(int i = 0 ; i < 100000000 ; i++){
userMethod();
}
System.out.println("------运行结束------");
}
public static void userMethod(){
User user = new User();
user.setId(58);
user.setName("CGgeeker");
}
}
运行结果可以看出控制台打印了大量GC日志:
(2)设置参数关闭逃逸分析和变量替换:-XX:+DoEscapeAnalysis -XX:+EliminateAllocations
运行结果可以看出程序没有产生GC日志:
开始证明第二个观点:
public class EscapeAnalysisTest {
public static void main(String[] args) {
User user = null;
// 循环调用1亿次userMethod方法
for(int i = 0 ; i < 100000000 ; i++){
// userMethod();
user = userMethod2();
}
System.out.println("打印user:" + user);
System.out.println("------运行结束------");
}
public static User userMethod2(){
User user = new User();
user.setId(58);
user.setName("CGgeeker");
return user;
}
}
从运行结果看到控制台打印了大量的GC日志,从而论证了逃逸对象不会在栈上分配的结论。
2.对象在Eden区分配
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,JVM将发起一次Minor GC。
Minor GC 和 Full GC:
- Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
- Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。
Eden与Survivor区默认8:1:1
大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有90%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的(注意:这个比例是经过大量测试得到的比较合适的一个比例),让eden区尽量的大,survivor区够用即可。
JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
示例:
// 我设置了这些参数: -Xmx120m -Xms120m -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails
public class MinorGCTest {
public static void main(String[] args) throws InterruptedException {
byte[] arr1, arr2, arr3, arr4, arr5, arr6;
arr1 = new byte[25 * 1024 * 1024]; // 大概25M ,25 * 1024K
// arr2 = new byte[1 * 1024 * 1024]; // 大概1M ,1024K
}
}
我感觉我把打印结果放上来,很多小伙伴都会对这个结果产生疑惑,
- 疑惑1:为什么设置了参数 -Xmx120m -Xms120m 后,按理说给堆分配了120M的内存空间,新生代和老年代按照 1:2的比例分配后,那新生代也应该有40M空间才对啊,怎么上图中1处这里只有35840K,大概35M的样子?what fuck?
- 解释1:上图中的total 35840K 实际上只是 eden区 + survior区中的一个小分区(from区 / to区) ,所以这里的35840K实际上就只是 eden space的30720K + from space的5120K。
- 疑惑2:为什么我设置了参数 -XX:-UseAdaptiveSizePolicy eden区和survior区比例却不是8:1:1?按理说关闭比例自动变化应该是8:1:1啊,怎么是6:1:1? what fuck?
- 解释2:声明一点啊,我这里具体的原因我也不清楚,但是不妨碍我们思考的方向,可能原因是要么就是这个参数必须在特定的垃圾收集器下使用,否则不生效;还可能就是默认比例调整了。
- 疑惑3:为什么eden space使用率就直接飙满达到100%了?不是在程序中 ”arr1 = new byte[25 * 1024 * 1024];“ 大概也就25M啊,按理说eden space空间是30M,25M还没达到30M怎么就是100%呢?
- 解释3:原因是即使程序什么也不做,新生代也会使用至少几M内存(小生不才,暂时也不知道这几M数据具体是什么,等之后阅读Hotspot源码后来填坑吧),不过验证的过程还是写下面:
那我们干脆一点把所有变量中注释掉:
// 我设置了这些参数: -Xmx120m -Xms120m -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails
public class MinorGCTest {
public static void main(String[] args) throws InterruptedException {
// byte[] arr1, arr2, arr3, arr4, arr5, arr6;
// arr1 = new byte[25 * 1024 * 1024]; // 大概25M ,25 * 1024K
// arr2 = new byte[1 * 1024 * 1024]; // 大概1M ,1024K
}
}
控制台输出结果如下,那丢失的5M找到了!
我们继续研究当eden区满后再向堆中创建数据会发生什么:
// 我设置了这些参数: -Xmx120m -Xms120m -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails
public class MinorGCTest {
public static void main(String[] args) throws InterruptedException {
byte[] arr1, arr2, arr3, arr4, arr5, arr6;
arr1 = new byte[25 * 1024 * 1024]; // 大概25M ,25 * 1024K
arr2 = new byte[1 * 1024 * 1024]; // 大概1M ,1024K
}
}
通过控制台打印可以看出:因为给arr2分配内存的时候eden区内存几乎已经被分配完了,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间虚拟机又发现arr1无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的空间足够存放arr1,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。可以执行如下代码验证:
// 我设置了这些参数: -Xmx120m -Xms120m -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails
public class MinorGCTest {
public static void main(String[] args) throws InterruptedException {
byte[] arr1, arr2, arr3, arr4, arr5, arr6;
arr1 = new byte[25 * 1024 * 1024]; // 大概25M ,25 * 1024K
arr2 = new byte[1 * 1024 * 1024]; // 大概1M ,1024K
arr3 = new byte[1* 1024 * 1024];
arr4 = new byte[1* 1024 * 1024];
arr5 = new byte[1* 1024 * 1024];
arr6 = new byte[1* 1024 * 1024];
}
}
从上图看出:由于新生的对象在eden区还可以放下,所以不会挪到老年代。
3.大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,这个参数只在 Serial 和ParNew两个收集器下有效。如果对象超过设置大小会直接进入老年代,不会进入年轻代,这样做是为了避免为大对象分配内存时的复制操作而降低效率。
证明过程如下:我们直接将arr1的值设置为50M的样子,这样就比年轻代总空间40M大了,但是比老年代80M小(保证了不会发生Full GC),所以这个对象会被直接挪到老年代。
// 我设置了这些参数: -Xmx120m -Xms120m -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails
public class MinorGCTest {
public static void main(String[] args) throws InterruptedException {
byte[] arr1, arr2, arr3, arr4, arr5, arr6;
arr1 = new byte[50 * 1024 * 1024]; // 大概50M ,50 * 1024K
}
}
4.长期存活的对象将进入老年代
虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
5.对象动态年龄判断
当前放对象的Survivor区域里(其中一块区域,放对象的那块S区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1岁 + 年龄2岁 + 年龄n岁的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n岁(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在Minor GC之后触发的。
6.老年代空间分配担保机制
年轻代每次Minor GC之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了。如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果结果是小于或者之前说的参数没有设置,那么就会触发一次Full GC,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM" 。当然,如果Minor GC之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发Full GC,Full GC完之后如果还是没有空间放Minor GC之后的存活对象,则也会发生“OOM”。
四.聊聊对象内存回收
垃圾收集器回收堆中对象时,首先会判断哪些对象是需要被回收的垃圾对象,这里就涉及到判断垃圾对象的两种方法:
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用到这个对象,计数器就+1;当引用失效(比如将某个对象的引用赋值为null),计数器就 -1;当某个对象的计数器值为0时,说明这个对象没有任何引用与之关联,则该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。
优点:实现简单,效率高。
缺点:没有解决对象之间的环引用(循环引用)问题,容易导致在环内的对象一直得不到回收,所以在Java中并没有采用这种方式(Python采用的是引用计数法)。
例如下面这段代码就存在环引用问题:
public class ReferenceCountTest {
static class MyObj{
public Object instance = null;
}
public static void main(String[] args) {
// 创建两个ReferenceCountTest对象分别被 object1 和 object2引用 此时 引用计数器都是 1
MyObj object1 = new MyObj();
MyObj object2 = new MyObj();
object1.instance = object2;
object2.instance = object1;
object1 = null;
object2 = null;
}
}
可达性分析算法
为了解决这个问题,在Java中采取了 可达性分析法。该方法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。以下图为例:
在Java语言中,可作为GC Roots的对象包含以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中(Native方法)引用的对象。
谈谈引用类型
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。
强引用:强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
public static SoftReference<User> user = new SoftReference<User>(new User());
软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的场景:
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建。
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少使用。
public static WeakReference<User> user = new WeakReference<User>(new User());
虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。
如何判断一个类是无用的类
方法区主要回收的是无用的类,怎么判断一个类有用还是无用?
类需要同时满足下面3个条件才能算是 “无用的类” :
- 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。