一、垃圾回收

1.1 如何判断对象是否可以回收

1.1.1 引用计数法

A、B对象循环引用,但是别的地方没有使用A、B了。由于A、B的引用计数都为1,所以无法回收,造成内存泄漏。

1.1.2 可达性分析算法

JAVA使用这个算法

确定根对象GC Root(肯定不能被垃圾回收的对象)。判断每个对象是不是被根对象直接或间接的引用,如果是,则不能作为垃圾回收

1.1.3 五种引用

image-20260325155951945

  • 引用本身也是一个对象,当软/弱引用的对象被回收后,可以将软引用/弱引用自己(对象)放入引用队列,然后通过引用队列进一步释放他们自己的对象所占的空间。
  • 虚引用/终结器引用必须配合引用队列一起使用

(1) 强引用

定义:Object obj = new Object(); 这种方式的赋值

特点:只要引用关系还存在,GC(垃圾回收器)就永远不回收被引用的对象。

(2) 软引用

特点:当系统内存充足时不会回收软引用的对象;但当内存不足时,回收软引用的对象。

(3) 弱引用

特点:只要发生了垃圾回收,不管内存是否充足,都会回收弱引用的对象。

(4) 虚引用

特点:当ByteBuffer对象被GC回收时,虚引用进入引用队列,一个线程使用Unsafe.freeMemory把直接内存释放掉

(5) 终结器引用

特点:第一次GC先将终结器引用对象自己放入引用队列,执行finalize()(优先级很低)。下一次GC的时候才会回收内存。

不推荐使用。

1.2 回收算法

1.2.1 标记清除

image-20260325202510044

  1. 扫描内存,标记被GC Root直接或间接引用的对象。
  2. 清除没有被标记的对象(记录首地址和尾地址,下次使用的时候填入新内容即可)

缺点:内存中存在很多碎片,没有大块大块的空闲区域

1.2.2 标记整理

image-20260325203236393

和标记清除的区别:标记整理的第二步是进行整理,把没回收的部分全部紧凑到一起(向一端移动)。

优点:提供大块的空闲区域,没有内存碎片

缺点:需要更改地址,耗时长

1.2.3 复制算法

image-20260325204242439

image-20260325204309760

先从FROM区域把可达的全部复制到TO区域,然后把TO区域的地址和FROM区域的地址对调。

优点:不会产生碎片

缺点:会占用双倍的内存空间

标记整理和复制算法的不同用途

新生代中“垃圾多、存活少”,复制算法只需要复制极少数存活的内容。(为什么不用标记整理?在存活少的情况下,复制算法更加快。)

老年代中存活率极高,采用复制算法需要复制大量内容,需要两个差不多大的内存区域,非常浪费。所以使用标记整理。

1.3 分代垃圾回收

image-20260326154715232

  1. 先放在伊甸园,当伊甸园内存不够后,执行一次垃圾回收(可达性分析+复制算法),仍然存活的对象进入幸存区的内容寿命加一
  2. 如果伊甸园又满了,进行第二次垃圾回收(可达性分析+复制算法),同时还要对幸存区的内容进行垃圾回收(不用了就回收,还要用就放到FROM区域)。如果寿命超过一定的值(最大寿命是15),将内容放置到老年代
  • 新生代自己的垃圾回收叫Minor GC(Minor GC会引发stop the world,垃圾回收时其他线程全部暂停。)
  • 新生代和老年代内存都不够了,触发Full GC
  • 当老年代空间不足,会先尝试触发Minor GC,如果之后空间仍然不足,则触发Full GC(标记清除/标记整理)
  • 如果有一个大对象来,新生代(伊甸园/幸存区)自己的内存都装不下这个对象,那就直接放到老年代。

相关参数设置:

image-20260326155223392

注:线程内出现了OOM,不会导致整个进程结束。

1.4 垃圾回收器

1.4.1 串行

  • 单线程
  • 堆内存较小,适合个人电脑

image-20260326203730096

在新生代中采用复制算法

在老年代中采用标记整理算法

1.4.2 吞吐量优先

  • 多线程
  • 堆内存较大,多核CPU
  • 让单位时间内,STW的时间最短

image-20260326212025426

UseAdaptiveSizePolicy:自适应(新生代)大小调整,动态调整伊甸园、幸存区的比例,和整个堆的大小。

蓝色的矛盾点:

  • 如果需要GC时间占总时间比率低(吞吐量大),那么需要调大堆内存。

  • 如果需要GC的时间低(垃圾清理得快),那么需要调小堆内存。

image-20260326214320586

1.4.3 响应时间优先 CMS (只针对老年代)

  • 多线程
  • 堆内存较大,多核CPU
  • 尽可能让单次STW的时间最短(单位时间内的总STW时间不一定短)

image-20260328141340580

-XX:CMSInitiatingOccupancyFraction=percent当内存到百分之多少的时候就触发GC,给并发运行的程序预留塞入新对象和浮动垃圾的空间

  • 初始标记:寻找并标记被GC Roots直接关联的对象。
  • 并发标记:从上一步找到的对象出发,沿着引用链遍历整个对象图,把所有存货的对象都标记出来。
  • 重新标记:全局扫描,修正在上一步(并发标记)阶段中发生变动的那部分对象的记录。(耗时大于初始标记,但远小于并发标记)
  • 并发清理:根据确定的标记结果,将没有被标记的垃圾对象所占内存进行回收。(但是其他程序仍在运行、产生垃圾,只能等到下一次GC再回收——>浮动垃圾)

弊端:产生内存碎片

1.4.4 G1

Garbage First

image-20260328143913402

G1将连续的JAVA堆划分为多个大小相等的独立区域(Region),每个区域根据需要扮演新生代的伊甸园、幸存区或老年代区域。收集器针对扮演不同角色的Region采用不同的策略去处理。

  • 避免了JAVA堆全区域的垃圾回收
  • 每个Region有不同的价值(回收所获得的空间大小以及回收所需时间的经验值),后台维护一个优先级列表,优先处理回收价值收益最大的Region——Garbage First

(1)G1垃圾回收阶段

Young GC 新生代回收

image-20260328153942200

全程 STW,纯复制算法。

Young GC + CM
  • 在Young GC时进行GC Root的初始标记
  • 当老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),由下面的JVM参数决定。

image-20260328154153046

Mixed Collection

对E、S、O进行全面的垃圾回收

-XX:MaxGCPauseMillis=time指定最大停顿时间,默认200ms

image-20260328153130104

老年代的回收流程:

  • 初始标记:寻找并标记被GC Roots直接关联的对象。STW(搭便车,在Young GC的时候就扫描老年代进行标记了,因为反正Young GC需要STW)

  • 并发标记:从上一步找到的对象出发,沿着引用链遍历整个对象图,把所有存货的对象都标记出来。


    下面两个阶段是在并发标记之后,等到下一次Young GC执行的时候,升级为Mixed GC

  • 最终标记:全局扫描,修正在上一步(并发标记)阶段中发生变动的那部分对象的记录。(耗时大于初始标记,但远小于并发标记)STW

  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,然后会根据用户期望的停顿时间来指定回收计划,把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。(复制算法)STW

如果最大停顿时间设置的太短了,会导致每次垃圾回收只能回收很小的一部分,回收的速度低于垃圾产生的速度,最终导致堆被占满,引发Full GC,反而降低性能。

image-20260328153022428

(2)Full GC辨析

image-20260328153408879

(3)跨代引用问题

在Young GC过程中,如果出现不同Region的年轻代对象互相引用的情况,垃圾回收器是可以识别出这种引用关系的。因为关注的是年轻代内存区域中的所有对象。

image-20260328160930051

然而,如果由老年代对象引用了年轻代对象,这是就无法识别到这个对象,会被错误回收。

记忆集

记录谁指向了我

G1引入记忆集(RememberSet),详细记录了非收集区域(老年代)对象引用收集区域(年轻代)对象的关系。在YoungGC时,记忆集中的对象会被临时加入到GC Root中,这样垃圾回收器就可以根据引用链准确判断哪些对象需要收集。

image-20260328161246843

为了进一步优化内存使用,G1采用了分块记录而非逐个记录的策略。把每个Region按照一定大小划分成多个块,并给每个块编号。在记忆集中,记录存在跨代引用的对象所在的块,减小内存开销。

image-20260328161533817

卡表

记录是否存在往外指向

整个内存只有一张卡表(Card Table),用于记录每个Region中对象的跨代引用情况。如果某个Region中存在跨代引用别的对象的时候,相应Region对应卡表的数组元素的值就表示为1,即脏卡(Dirty Card)

注:实则是只要有引用写操作,对应内存块所在的卡就会被标记为脏卡。

(4)并发-重新标记

三色标记法

黑色:在GC Root引用链中,并且它引用的子对象已经 完成标记

灰色:在GC Root引用链中,但是它引用的子对象没有 完成标记

白色:最开始是还未被访问;标记结束后是没有人引用它,可以回收

SATB 初始快照

由于并发标记的时候是和业务代码并发执行的,很有可能在A标记为黑色之后,把C的引用切换到了A下面,C没有别的引用了。导致垃圾回收器检索不到C的引用,错误回收C。

SATB是在并发标记阶段时,当业务线程执行类似于 B.c = null 这种断开引用的操作时,写前屏障会立刻拦截,把即将“失联”的旧对象 C 抢救下来,并扔进 SATB 队列中

重新标记阶段时,把SATB中的对象全部拿出来进行处理。

image-20260328171354169

参考资料:JVM工作原理与实战(三十九):G1垃圾回收器原理-阿里云开发者社区

JDK 8 字符串去重

image-20260328190245729

JDK 8 并发标记类卸载

image-20260328190704022

JDK 8 回收巨型对象

  • 一个对象大于region的一半时,称之为巨型对象
  • G1不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1会跟踪老年代所有incoming引用,这样老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉(会先在遍历的时候看看有没有新生代对象引用它,没有才会回收)

JDK 9 并发标记起始时间的调整

image-20260328192531726

1.5 垃圾回收调优

  • 内存
  • 锁竞争
  • cpu占用
  • io

1.5.1 确定目标

  • 【低延迟】还是【高吞吐量】,选择合适的回收器
  • 低延迟:CMS, G1, ZGC
  • 高吞吐量:ParallelGC

1.5.2 最快的GC是不发生GC

  • 查看FullGC前后的内存占用,考虑下面几个问题:
    • 数据是不是太多?
      • resultSet = statement.executeQuery(“select * from 大表”) 这个就提取了太多数据
    • 数据表示是否太臃肿?
      • 对象图
      • 对象大小
    • 是否存在内存泄漏?
      • 解决方法:弱/软引用,第三方缓存实现

1.5.3 新生代调优

  • 新生代的特点

    • 所有new操作的内存分配非常廉价‘
      • TLAB 线程对象分配缓冲区,保证在同时分配的时候不要抢占同一块内存。
    • 死亡对象的回收代价是0(新生代采用复制算法)
    • 大部分对象用过即死
    • Minor GC的时间远远低于Full GC
  • -Xmn 设置新生代的初始和最大大小(字节)。

    • 设置小了,minor GC会频繁运行
    • 设置大了,只会执行full GC,耗时较长
    • Oracle建议设置为总堆大小的25%到50%
    • 最好设置为新生代能容纳所有【并发量*(请求响应)】的数据
  • 幸存区大到能保留【当前活跃对象+需要晋升的对象】

    • 如果幸存区小的话,对象可能会提前晋升到老年代,到时候引发Full GC会消耗大量时间。
    • 所以想尽量把对象留在幸存区中
  • 晋升阈值配置得当,让长时间存货对象尽快晋升

    • 长时间存活的对象一直在幸存区中,会导致每次Minor GC的时候被复制来复制去,浪费了时间

image-20260329115436268

1.5.4 老年代调优

以CMS为例

  • 老年代内存越大越好
  • 先尝试不做调优,如果没有Full GC那么不需要调优,否则先尝试调优新生代
  • 观察发生Full GC时老年代内存占用,将老年代内存预设调大1/4~1/3
    • -XX:CMSInitiatingOccupancyFraction=percent

二、类加载器

2.1 分类

image-20260329154326339

加载一个类的时候,先问本加载器是否加载了对应的类,没有的话问扩展类加载器有没有加载,没有的话再问bootstrap(启动类加载器)有没有加载。然后从启动类加载器开始尝试加载,不行的话就退回下一级进行尝试。最后才轮到应用类加载器进行加载。

优点:有优先级的层次关系,可以保证最基础的功能不被自定义的设置破坏。

2.2 双亲委派

指调用类加载器的loadClass方法时,查找类的规则。

image-20260329163226211

2.3 线程上下文类加载器

如果一个基础服务的代码(由启动类加载器加载)中用到了不同厂商对于一个服务接口的实现代码,而这些代码很明显无法由启动类加载器加载,这时候就需要线程上下文加载器来让父加载器去请求子加载器完成加载任务。

(默认是应用程序类加载器

2.4 自定义类加载器

什么时候需要自定义类加载器:

  1. 想加载非classpath随意路径中的类文件
  2. 都是通过接口来使用实现,希望解耦时,常用在框架设计
  3. 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于tomcat容器

步骤:

image-20260329201925181

三、CAS

Compare and Swap,体现乐观锁的思想,比如多个线程要对一个共享的整型变量执行+1操作:

以下为伪代码,参数没写全

image-20260329210523335

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

3.1 乐观锁与悲观锁

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我再重试。
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上来锁你们都别想改,我改完了,解开锁,你们才有机会。

3.2 原子操作类

juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用CAS技术+volatile实现的。

3.3 synchronized 优化

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的哈希码分代年龄,当加锁时,这些信息就根据情况被替换为标记位线程锁记录指针重量级锁指针线程ID等内容

3.3.1 轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。