资料来源:https://www.bilibili.com/video/BV1yE411Z7AP?p=2

一、内存结构

  1. 程序计数器
    2. 虚拟机栈
    3. 本地方法栈
    4. 堆
    5. 方法区

    1、程序计数器

    image.png

    1.1 定义

    Program Counter Register程序计数器(寄存器)
    作用:是记住下一条jvm指令的执行地址
    特点
    是线程私有的
    不会存在内存溢出(唯一一个不会出现内存溢出的区域)

    1.2 作用

    1. 0:getstatic #20 //PrintStreamout=System.out;
    2. 3:astore_1 //--
    3. 4:aload_1 //out.println(1);
    4. 5:iconst_1 //--
    5. 6:invokevirtual #26 //--
    6. 9:aload_1 //out.println(2);
    7. 10:iconst_2 //--
    8. 11:invokevirtual #26 //--
    9. 14:aload_1 //out.println(3);
    10. 15:iconst_3 //--
    11. 16:invokevirtual #26 //--
    12. 19:aload_1 //out.println(4);
    13. 20:iconst_4 //--
    14. 21:invokevirtual #26 //--
    15. 24:aload_1 //out.println(5);
    16. 25:iconst_5 //--
    17. 26:invokevirtual #26 //--
    18. 29:return
    image.png

    2、虚拟机栈

    image.png

    2.1 定义

    Java Virtual Machine Stacks (Java 虚拟机栈)
    1)每个线程运行时所需要的内存,称为虚拟机栈
    2)每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
    3)每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
    image.png
    image.png
    问题辨析:
    1)垃圾回收是否涉及栈内存?
    不涉及,栈内存是一次次的方法调用所产生的栈帧内存,而栈帧内存在每次方法调用结束后,都会被弹出栈,自动的被回收掉,所以不需要垃圾回收来管理栈内存。垃圾回收是管理堆内存中的无用对象

2)栈内存分配越大越好吗?
① 栈内存越大,会使得线程数变少,因为物理内存的大小是固定的,线程是占用的栈内存,栈内存设置的越高,线程的数量就会变少(物理内存 = 线程数 * 设置的栈内存)
② 栈内存越大,利于更多次的方法递归调用,但不会增强运行效率,反而会影响线程数变少,所以栈内存不建议设置过大(系统默认设置的即可)

3)方法内的局部变量是否线程安全?
image.png
① 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的(一个线程对应一个栈,线程内每次方法调用都会产生一个新的栈帧,局部变量是线程私有的,每个线程独立运行,互不干扰)
image.png
② 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
image.png

2.2 演示栈帧

image.png

2.3 栈内存溢出

1)栈帧过多导致栈内存溢出
栈帧总内存超过栈分配的内存,导致其他栈帧无法进栈。比如方法递归调用,没有设置正确的结束条件,不断调用自身方法
image.png
2)栈帧过大导致栈内存溢出,默认的栈内存在1M,int占用4个字节,所以一般不会出现这种情况
image.png
image.png
image.png
image.png

2.4 线程运行诊断

案例1:cpu占用过多
image.png
定位:
1)用top定位哪个进程对cpu的占用过高
image.png
2)ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)
image.png
3)jstack 进程id:可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号
image.png
案例2:程序运行很长时间没有结果
image.png
image.png
出现死锁的代码:
image.png

3、本地方法栈

image.png
Java虚拟机在调用本地方法时,需要给本地方法提供的内存空间,这些本地方法运行时所使用的内存就是本地方法栈
本地方法:指那些不是由Java代码编写的方法,Java代码有一定的限制,不能直接与操作系统底层交互,所以就需要C或C++编写的本地方法来与操作系统底层的API打交道。Java代码可以通过本地方法间接的调用操作系统底层功能
image.png

4、堆

image.png
程序计数器、虚拟机栈、本地方法栈都是线程私有的
堆和方法区都是线程共享的

4.1 定义

Heap堆:通过new关键字,创建对象都会使用堆内存
特点
1)它是线程共享的,堆中对象都需要考虑线程安全的问题
2)有垃圾回收机制,堆中未使用的对象都会被回收,释放内存

4.2 堆内存溢出

** 对象可以当做垃圾被回收的条件是对象没有被使用,如果不断产生的对象有被使用,所以就都不能被回收,对象达到一定数量后就会导致堆内存耗尽
image.png

4.3 堆内存诊断

1)jps工具

查看当前系统中有哪些java进程
image.png

2)jmap工具

查看某个时刻堆内存占用情况:jmap -heap 进程id
1个byte就是1个字节,1024byte = 1K, 1024K = 1M 。堆空间会多10M的内存占用
image.png
image.png

3)jconsole 工具

图形界面的,多功能的监测工具,可以连续监测,还可以监控线程、CPU
image.png

4)jvisualvm工具

垃圾回收后,内存占用仍然很高
image.png
image.png

5、方法区

image.png

5.1 定义

IVM翅范-方法区宁义

1、方法区存储的是跟类相关信息,如方法、构造器、成员方法、运行时常量池、类加载器
2、在虚拟机启动时创建方法区,逻辑上是堆的组成部分,并不强制方法区的位置
方法区是规范,永久代和元空间都是它的实现

5.2 组成

JVM1.6方法区占用的是堆内存空间
image.png
JVM1.8方法区占用的是本地内存空间(默认没有设置上限),垃圾回收也由自己控制。本地内存即操作系统的内存,它还包含其他的进程
image.png

5.3 方法区内存溢出

1、1.8以前会导致永久代内存溢出
image.png
2、1.8之后会导致元空间内存溢出
image.png

场景:
spring和mybatis都存在动态加载类,在运行期间生成大量的类,容易导致永久代的内存溢出

5.4 运行时常量池

常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量
等信息
image.png
运行时常量池,常量池是存到*.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量
池,并把里面的符号地址变为真实地址
image.png

5.5 StringTable

先看几道面试题:

  1. String s1 = "a";
  2. String s2 = "b";
  3. String s3 = "a" + "b"; // ab -> 在串池中
  4. String s4 = s1 + s2; // new String("ab") -> 在堆中的对象
  5. String s5 = "ab"; // 常量直接到串池中查找
  6. String s6 = s4.intern(); // 将堆中的对象放入串池,并返回串池中的对象
  7. // 问
  8. System.out.println(s3 == s4); // false
  9. System.out.println(s3 == s5); // true
  10. System.out.println(s3 == s6); // true
  11. String x2 = new String("c") + new String("d"); // new String("cd") -> 堆中的对象
  12. String x1 = "cd"; -> 常量池中的对象
  13. x2.intern();
  14. // 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢,如果是jdk1.8就为true
  15. System.out.println(x1 == x2); // false 串池中没有字符串对象,就会把堆中的对象复制一份到串池中,但x2仍是堆中的对象

5.5.1 StringTable 特性

1、常量池中的字符串仅是符号,第一次用到时才变为对象
1)执行到String s1 = “a”;这行代码的时候才会创建”a”字符串对象(用到的时候才会创建字符串对象,不会提前创建)
2)创建字符串对象前,会到StringTable串池(最初是空的)查询是否存在该字符串对象,若StringTable串池中没有该字符串对象,就会创建该字符串对象并放入StringTable串池中。如果StringTable串池有该字符串对象就不会创建字符串对象,直接使用串池中的字符串对象。每个字符串对象在StringTable串池中是唯一的
image.png
2、利用串池的机制,来避免重复创建字符串对象
image.png
3、字符串变量拼接的原理是StringBuilder (1.8 )
image.png
4、字符串常量拼接的原理是编译期优化
image.png
5、可以使用intern方法,主动将串池中还没有的字符串对象放入串池
1)1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
image.png
2)1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
image.png**

5.5.2 StringTable 位置

image.png
image.png
image.png
image.png

5.5.3 StringTable 垃圾回收

没有被使用的字符串常量也会被垃圾回收。
image.png

5.5.4 StringTable 性能调优

1、调整:-XX:StringTableSize=桶**image.png
image.png
2、考虑将字符串对象(堆中)是否入池(常量池StringTable)
若堆中存在大量重复的字符串对象,可以考虑将这些对象放入常量池StringTable(字符串对象不重复),可以节省堆空间内存
image.png
image.png

6、直接内存

直接内存:是操作系统的内存

6.1 定义

Direct Memory
1)常见于 NIO 操作时,用于数据缓冲区
image.png
2)分配回收成本较高,但读写性能高
3)不受 JVM 内存回收管理,也会出现内存溢出
image.png

6.2 分配和回收原理

1、使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
2、ByteBuffffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffffer 对象,一旦ByteBuffffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用freeMemory 来释放直接内存

二、垃圾回收

1 .如何判断对象可以回收
2. 垃圾回收算法
3. 分代垃圾回收
4. 垃圾回收器
5. 垃圾回收调优

1、如何判断对象可以回收

1.1 引用计数法

1、对象被变量引用1次,对象的引用计数就+1,被变量引用N次,对象的引用计数就+N,若变量不再引用该对象,它的引用计数就为0,表示可以被垃圾回收了
2、弊端:若两个对象只有互相引用,而不涉及其他变量引用这两个对象,它们的引用计数都为1。此时就不能被垃圾回收
image.png

1.2 可达性分析算法

1、Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
2、扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,若找不到表示可以被回收。哪些对象可以作为 GC Root ?
image.png
3、根对象:那些肯定不能被当成垃圾的对象
4、垃圾回收之前,会扫描堆中的所有对象,判断对象是否直接或间接的被根对象所引用,若是,则该对象就不能被回收,反之若对象没有直接或间接被根对象所引用,则可以被当做垃圾回收。**

1.3 四种引用

1、强引用

只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。

2、软引用(SoftReference)

1)所有根对象没有直接引用该对象,仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象
3)可以配合引用队列来释放软引用自身。
image.png

3、弱引用(WeakReference)

1)仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
2)可以配合引用队列来释放弱引用自身。
image.png

4、虚引用(PhantomReference)

必须配合引用队列使用,主要配合 ByteBuffffer 使用,被引用对象回收时,会将虚引用入队,由Reference Handler 线程调用虚引用相关方法释放直接内存。

5、终结器引用(FinalReference)

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 fifinalize方法,第二次 GC 时才能回收被引用对象
p54

2、垃圾回收算法

2.1 标记清除

定义: Mark Sweep

原理:并不会将占用的内存做清零处理,而是记录对象的起始和结束地址,放入空闲的地址列表,下次有新对象需要分配内存时,到地址列表中查找是否有足够的空间来容纳新对象

• 速度较快
清除操作只需要记录垃圾对象的起始和结束地址即可,不需要做更多的额外处理,所以垃圾回收速度较快
• 会造成内存碎片
回收的空间不连续,新的大对象塞不进去,造成内存溢出
image.png

2.2 标记整理

定义:Mark Compact
• 速度慢
清理的过程中将可用的对象向前移动,使对象紧凑
• 没有内存碎片
image.png

2.3 复制

定义:Copy

步骤一:在FROM区标记垃圾对象
步骤二:将FROM区的存活对象移动至TO区
步骤三:将TO区的对象移动至FROM区。TO总是空闲的空间

• 不会有内存碎片
• 需要占用双倍内存空间
image.png

3、分代垃圾回收

上述三种算法在JVM中都会涉及,根据分代垃圾回收机制协同工作

老年代:存放长时间使用的对象,垃圾回收频率低
新生代:存放用完即丢弃的对象,垃圾回收频率高
针对对象生命周期不同的特点,进行不同的垃圾回收策略。
针对不同的区域,采用不同的垃圾回收算法,就可以更有效的对垃圾回收进行管理
image.png
执行步骤:

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发第一次Minor GC释放新生代中的垃圾对象所占的内存空间,伊甸园和From存活的对象使用复制算法复制到 To区中,存活的对象年龄加1并且交换From、To

image.png
新生代空间不足时,触发第二次Minor GC,幸存区中的对象也会进行垃圾回收操作,存活的对象年龄加1
image.png

  • Minor GC会引发Stop The World,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

对象从From区复制到To区时,地址会产生变化,若多个线程同时运行,会造成线程混乱,出现找不到对象的情况。STW的时间较短(采用的是复制算法,同时新生代的对象容易清除,而且复制的对象也不太多)

  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(但可能小于15次就晋升到老年代)(4bit)

image.png

  • 当老年代空间不足,会先尝试触发Minor GC释放新生代空间,如果之后空间仍不足,那么触发Full GC(对新生代和老年代空间均进行垃圾回收),也会引发Stop The World,STW的时间更长(采用的是标记整理或标记清除算法,同时老年代中的对象不易清除)。
  • 若Full GC后空间仍不足,会报异常,内存空间不足

image.png

3.1 相关VM参数

含义 参数
堆初始大小 -Xms
堆最大大小 -Xmx 或 -XX:MaxHeapSize=size
新生代大小 -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例 -XX:SurvivorRatio=ratio
晋升阈值 -XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
GC详情 -XX:+PrintGCDetails -verbose:gc
FullGC 前 MinorGC -XX:+ScavengeBeforeFullGC

3.2 GC分析

1、未运行任何对象时,堆内存的占用情况
image.png
2、内存中放入7M的对象
image.png
image.png
3、大对象直接晋升到老年代,也不会引起新生代的GC
image.png
4、内存溢出
image.png
5、线程内的内存溢出,不会造成主线程的异常退出
image.png

4、垃圾回收器

三类垃圾回收器
1. 串行垃圾

  • 单线程
  • 堆内存较小,适合个人电脑
  1. 吞吐量优先
  • 多线程,适用于堆内存较大的场景,需要多核cpu支持
  • 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4(单位时间内只回收了2次,总共耗时0.4S),垃圾回收时间占比最低,这样就称吞吐量高(垃圾时间与运行时间的占比越低,吞吐量越高
  1. 响应时间优先
  • 多线程
  • 堆内存较大,需要多核cpu支持
  • 尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5,5次总共花了0.5S

    4.1 串行

    开启串行垃圾回收器的JVM指令:-XX:+UseSerialGC = Serial + SerialOld
    Serial:工作在新生代,采用的是复制算法
    SerialOld:工作在老年代,采用的是标记+整理算法
    image.png

    4.2 吞吐量优先

    -XX:+UseParallelGC ~ -XX:+UseParallelOldGC
    1)JDK1.8默认已开启上述两个并行垃圾回收器,两个都是多线程,若只开启其中一个,另一个也会同步开启
    2)垃圾回收器线程个数默认跟CPU核数相同,每次垃圾回收会占用所有的CPU,固会导致CPU占用100%
    3)UseParallelGC:新生代的垃圾回收器,采用复制算法
    4)UseParallelOldGC:老年代的垃圾回收器,采用标记+整理算法

-XX:+UseAdaptiveSizePolicy
采用自适应的大小调整策略,动态调整新生代的大小以及伊甸园和幸存区的比例,晋升阈值也会收到影响

-XX:GCTimeRatio=ratio
1)根据设定目的尝试调整堆大小,调整吞吐量目标,调整垃圾回收时间与总运行时间的占比
2)算法:1/(1+ratio),ratio默认是99,即默认垃圾回收的时间占比为0.01
3)若达不到0.01这个目标,ParallelGC回收器会尝试调整堆的大小来达到这个目标(增大堆空间,降低垃圾回收频率,从而降低垃圾回收时间占比)

-XX:MaxGCPauseMillis=ms 最大暂停毫秒数,默认值为200ms
注意点:GCTimeRatio与MaxGCPauseMillis冲突,调整TimeRatio即表示增大堆空间,提升吞吐量,但是可能会增大每次垃圾回收耗费的时间,所以没法达到MaxGCPauseMillis。反之,调整MaxGCPauseMillis使暂停时间变短,需要减小堆空间,这样会使得吞吐量降低。所以需要根据实际情况对GCTimeRatio与MaxGCPauseMillis取合理的值。一般情况将ratio设置为19

-XX:ParallelGCThreads=n 控制垃圾回收线程
image.png

4.3 CMS响应时间优先

开启参数:
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
1)UseConcMarkSweepGC:工作在老年代的垃圾回收器,基于标记+清除的垃圾回收算法,同时用户线程和垃圾回收线程并发运行,进一步减少了STW时间。并发运行可能会失败,此时会将UseConcMarkSweepGC变更为SerialOld(基于标记整理),串行执行
2)UseParNewGC:工作在新生代的垃圾回收器,基于复制算法

-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
ParallelGCThreads:垃圾回收线程数,默认是cpu核数
ConcGCThreads:默认是cpu核数的四分之一
-XX:CMSInitiatingOccupancyFraction=percent
控制CMS垃圾回收时的内存占用,用于存放浮动垃圾。默认设置80%
-XX:+CMSScavengeBeforeRemark
开关。在重写标记之前是否对新生代做一次垃圾回收(UseParNewGC)
image.png

4.4 G1垃圾回收器

定义:Garbage First

  • 2004 论文发布
  • 2009 JDK 6u14 体验
  • 2012 JDK 7u4 官方支持
  • 2017 JDK 9 默认,同时下线CMS垃圾回收器

适用场景:

  • 并发线程,垃圾回收线程与用户线程并发执行
  • 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是200 ms
  • 适用于超大堆内存,会将堆划分为多个大小相等的Region。每个区域都由伊甸园、幸村区、老年代
  • 整体上是标记+整理算法,两个区域之间是复制算法

相关 JVM 参数
-XX:+UseG1GC 开关,JDK1.9之前要手动开启,1.9之后是默认开启
-XX:G1HeapRegionSize=size 设置堆中各区域大小
-XX:MaxGCPauseMillis=time

1) G1垃圾回收阶段

image.png
阶段一:Young Collection,针对新生代的垃圾回收
阶段二:Young Collection + CM,针对新时代的垃圾回收 + 并发标记
阶段三:Mixed Collection,混合收集,对新生代和老年代都有一次规模较大的收集
以上三个阶段是循环收集的过程

2) Young Collection

  • 会 STW

image.png
image.png
image.png

3) Young Collection + CM

  • 在Young GC时会进行GC Root的初始标记(标记根对象,不会占用并发标记的时间)
  • 老年代占用堆空间比例达到阈值时,进行并发标记(从根对象出发标记其他对象。不会阻塞其他线程)(不会 STW),阈值由下面的 JVM 参数决定

-XX:InitiatingHeapOccupancyPercent=percent (老年代占用堆空间大小默认为45%)
image.png

4) Mixed Collection

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

  • 最终标记(Remark)会STW,最终标记是再并发标记后的标记,防止对象遗漏
  • 拷贝存活(Evacuation)会STW

-XX:MaxGCPauseMillis=ms

① 伊甸园区存活的对象复制到幸存区
② 幸存区中符合晋升条件的对象会晋升到老年代区
老年代区域:优先收集垃圾最多的区域,目的是使暂停时间变短
老年代复制对象:目的是保留存活对象,整理内存,减少内存碎片
image.png

5) Full GC

① SerialGC

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足发生的垃圾收集 - full gc

② ParallelGC

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足发生的垃圾收集 - full gc

③ CMS

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足。垃圾回收速度是高于用户线程产生垃圾的速度,此时不叫Full GC

④ G1

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足:老年代占堆内存45%以上时,触发并发标记和混合收集这两个阶段。
  • 若这两个阶段工作的过程中,垃圾回收速度是高于用户线程产生垃圾的速度,此时不叫Full GC,还是并发垃圾收集阶段(这两个阶段也存在暂停时间,但比较短暂,称不上Full GC)
  • 垃圾回收速度是低于用户线程新产生垃圾的速度,此时并发收集失败,退化为串行收集,此时叫Full GC,响应时间变长

6) Young Collection 跨代引用

新生代垃圾回收过程:首先找到根对象,然后进行可达性分析,再找到存活对象,存活对象复制到幸存区
有部分根对象来自于老年代,老年代的存活对象较多,若想通过遍历老年代查找根对象,效率较低。
通过卡表的技术,脏卡

  • 新生代回收的跨代引用(老年代引用新生代)问题

image.png

  • 卡表与 Remembered Set
  • 在引用变更时通过 post-write barrier + dirty card queue
  • concurrent refinement threads 更新 Remembered Set

image.png

7) Remark重标记

pre-write barrier + satb_mark_queue
image.png
并发标记阶段时对象的处理状态

8) JDK 8u20 字符串去重

优点:节省大量内存
缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
默认打开:-XX:+UseStringDeduplication

  1. String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
  2. String s2 = new String("hello"); // char[]{'h','e','l','l','o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1并发检查是否有字符串重复
  • 如果它们值一样,让它们引用同一个char[]
  • 注意,与 String.intern() 不一样
    • String.intern() 关注的是字符串对象
    • 而字符串去重关注的是char[]
    • 在 JVM 内部,使用了不同的字符串表

      9) JDK 8u40 并发标记类卸载

      所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类。
      JDK的类加载器(启动类加载器、扩展类加载器、应用程序类加载器)始终会存在,不会类卸载。只有自定义的类加载器才有类卸载的功能和需求
      -XX:+ClassUnloadingWithConcurrentMark 默认启用

10) JDK 8u60 回收巨型对象

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

image.png

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

  • 并发标记必须在堆空间占满前完成,否则退化为Full GC
  • JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent:指定老年代与堆空间的占比,默认是45%
  • JDK 9 可以动态调整

5、垃圾回收调优

预备知识

  • 掌握 GC 相关的 VM 参数,会基本的空间调整
  • 掌握相关工具
  • 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则

image.png

5.1 调优领域

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


5.2 确定目标

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

5.3 最快的GC

答案是不发生 GC

  • 查看 FullGC 前后的内存占用,考虑下面几个问题

    • 数据是不是太多?导致GC频繁,堆内存压力过大
      • resultSet = statement.executeQuery(“select * from 大表 limit n”)
    • 数据表示是否太臃肿? 加载对象过多的属性,造成堆内存不必要的浪费
      • 对象图
      • 对象大小:最少占16字节,Integer:24,int:4字节
    • 是否存在内存泄漏?

      • 定义静态的Map集合变量,不断的往里面放入对象,且不移除:static Map map =
      • 软引用
      • 弱 引用
      • 第三方缓存实现

        5.4 新生代调优

        1、新生代的特点
    • 所有的new操作的内存分配非常廉价,都是先分配到伊甸园的TLAB区域

      • TLAB:thread-local allocation buffffer,线程局部分配缓存区,让每个线程用自己私有的伊甸园内存来进行分配,多个线程即使同时创建对象,也不会造成内存占用的干扰
      • 伊甸园中创建效率较高
    • 死亡对象的回收代价是零
    • 大部分对象用过即死
    • Minor GC的时间远远低于Full GC

2、越大越好吗?
-Xmn Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery).
GC is performed in this region more often than in other regions. If the size for the young
generation is too small, then a lot of minor garbage collections are performed. If the size is too
large, then only full garbage collections are performed, which can take a long time to complete.
Oracle recommends that you keep the size for the young generation greater than 25% and less
than 50% of the overall heap size.
新生代过小,可用空间少,创建新对象时一旦发现新生代可用空间不足,触发新生代的Minor GC,会造成短暂的STW
新生代过大,老年代可用空间相对变少,新生代创建的对象都不会触发垃圾回收,但是老年代的空间紧张,那么再次触发垃圾回收,就是Full GC,暂停时间比Minor GC的暂停时间更长
吞吐量随着新生代内存的增大而增大,但是新生代过大,会导致Minor GC的STW时间过长,从而吞吐量会随之降低。
官方建议新生代的内存占总内存的四分之一到二分之一
新生代采用复制算法,包含两个阶段:标记和复制,其中复制占用用时间更长,因为它涉及对象的占用内存块移动,更新其他引用对象的地址

3、新生代能容纳所有【并发量 (请求-响应)】的数据
一次请求响应后,大部分对象会被回收
4、幸存区大到能保留【当前活跃对象+需要晋升对象】
幸存区晋升规则:*若幸存区较小,由JVM动态的调整晋升阈值
,导致部分对象提前晋升到老年代,这些对象可能是存活时间较短,稍后就会被Minor GC的对象。而此时进入老年代后,需要Full GC才会回收,变相延长了对象的存活时间

5、晋升阈值配置得当,让长时间存活对象尽快晋升
长时间存活对象只会耗费幸存区的内存,下次回收的时候又要将存活对象从to复制到from,新生代回收耗时主要在复制阶段。此时相当于白白浪费了多次的复制操作时间
-XX:MaxTenuringThreshold=threshold:调整最大晋升阈值
-XX:+PrintTenuringDistribution:显示不同年龄的对象占用空间的详细信息

  1. Desired survivor size 48286924 bytes, new threshold 10 (max 10)
  2. - age 1: 28992024 bytes, 28992024 total
  3. - age 2: 1366864 bytes, 30358888 total
  4. - age 3: 1425912 bytes, 31784800 total
  5. ...

5.5 老年代调优

以 CMS 为例:

  • CMS的老年代内存越大越好
    • CMS:低响应时间、并发的垃圾回收器,垃圾回收线程在工作的同时,其他用户线程也能够并发的执行,缺点是其他用户线程会产生新的垃圾,称之为为浮动垃圾。若产生的浮动垃圾又导致内存不足,会造成CMS并发失败,CMS垃圾回收器不能正常工作,随之退化为SerialOld(串行的老年代垃圾回收器),降低效率,响应时间边长。所以在规划老年代内存时,越大越好,这样就是为了预留更多的空间,避免浮动垃圾引起的并发失败
  • 先尝试不做调优,如果没有Full GC那么说明系统已经很OK了,否则先尝试调优新生代
  • 观察发生Full GC时老年代内存占用,将老年代内存预设调大1/4 ~ 1/3
    • -XX:CMSInitiatingOccupancyFraction=percent:设置老年代占堆内存多少时触发垃圾回收,值越低,老年代触发垃圾回收的时机就越早。一般设置75%-80%之间

      5.6 案例

案例1:Full GC 和 Minor GC频繁
问题:GC频繁,说明空间紧张。新生代空间紧张,当业务高峰期,创建大量的对象,很快就将新生代空间占满,同时还会造成幸存区空间紧张,它里面的对象的晋升阈值就会降低,导致很多阈值较短的对象被晋升到老年代中,会造成老年代的空间紧张,频繁发生GC
优化:从新生代开始,试着增大新生代的内存空间,Minor GC就不再频繁,同时增加幸存区的空间以及晋升的阈值,这样就可以让很多生命周期较短的对象,尽可能的留在新生代被回收,而不是快速晋升到老年代后再被回收,老年代也就不会容易出现Full GC

案例2:请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
问题:CMS分为三个阶段:初始标记、并发标记、重新标记、并发清理,GC日志中会有每个阶段的耗费时间。重新标记要扫描整个堆内存,业务高峰期,新生代对象较多,此时重新标记扫描时间长,根据对象去找引用是一种遍历算法,非常耗时。
优化:重新标记之前针对新生代做一次垃圾回收,减少新生代对象的数量,从而减少重新标记阶段所耗费的时间。开启:-XX:+CMSScavengeBeforeRemark

案例3:老年代充裕情况下,发生Full GC(CMS jdk1.7)
问题: jdk1.7是采用永久代作为方法区的实现,永久代空间不足,也会导致整个堆的Full GC
jdk1.8是采用元空间,它的垃圾回收不是由Java控制,而且元空间的内存空间是采用的系统的内存空间,空间容量一般比较充裕,不会发生元空间空间不足的问题
优化:增大永久代的初始值和最大值,保证Full GC不会再发生

四、内存模型

1、java内存模型

很多人将[java内存结构】与[java内存模型】傻傻分不清,[java内存模型】是Java Memory Model (JMM )的意思。关于它的权威,请参考https://download.oracle.com/otn-pub/jcD/memorv model-1.0-pfd- spec-oth-ISpec/memory model-1 O-pfd-spec.pdf?
AuthParam=1 562811549 4d4994cbd5b59d964cd2907ea22ca08b

简单的说,JMM定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序 性、和原子性的规则和保障

1.1 原子性

原子性在学习线程时讲过,下面来个例子简单回顾一下:
问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是0吗?

1.2 问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

  1. getstatic i // 获取静态变量i的值
  2. iconst_1 // 准备常量1
  3. iadd // 加法
  4. putstatic i // 将修改后的值存入静态变量i

而对应 i— 也是类似:

  1. getstatic i // 获取静态变量i的值
  2. iconst_1 // 准备常量1
  3. isub // 减法
  4. putstatic i // 将修改后的值存入静态变量i

image.png
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

  1. // 假设i的初始值为0
  2. getstatic i // 线程1-获取静态变量i的值 线程内i=0
  3. iconst_1 // 线程1-准备常量1
  4. iadd // 线程1-自增 线程内i=1
  5. putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
  6. getstatic i // 线程1-获取静态变量i的值 线程内i=1
  7. iconst_1 // 线程1-准备常量1
  8. isub // 线程1-自减 线程内i=0
  9. putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行(为什么会交错?思考一下):
1)出现负数的情况:

  1. // 假设i的初始值为0
  2. getstatic i // 线程1-获取静态变量i的值 线程内i=0
  3. getstatic i // 线程2-获取静态变量i的值 线程内i=0
  4. iconst_1 // 线程1-准备常量1
  5. iadd // 线程1-自增 线程内i=1
  6. putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
  7. iconst_1 // 线程2-准备常量1
  8. isub // 线程2-自减 线程内i=-1
  9. putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

2)出现正数的情况:

  1. // 假设i的初始值为0
  2. getstatic i // 线程1-获取静态变量i的值 线程内i=0
  3. getstatic i // 线程2-获取静态变量i的值 线程内i=0
  4. iconst_1 // 线程1-准备常量1
  5. iadd // 线程1-自增 线程内i=1
  6. iconst_1 // 线程2-准备常量1
  7. isub // 线程2-自减 线程内i=-1
  8. putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
  9. putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1

1.3 解决方法

语法

  1. synchronized( 对象 ) {
  2. 要作为原子操作代码
  3. }

用 synchronized 解决并发问题:

  1. static int i = 0;
  2. static Object obj = new Object();
  3. public static void main(String[] args) throws InterruptedException {
  4. Thread t1 = new Thread(() -> {
  5. for (int j = 0; j < 5000; j++) {
  6. synchronized (obj) {
  7. i++;
  8. }
  9. }
  10. });
  11. Thread t2 = new Thread(() -> {
  12. for (int j = 0; j < 5000; j++) {
  13. synchronized (obj) {
  14. i--;
  15. }
  16. }
  17. });
  18. t1.start();
  19. t2.start();
  20. t1.join();
  21. t2.join();
  22. System.out.println(i);
  23. }

如何理解呢:你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。
1、当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++ 代码。
2、这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。
3、当 t1 执行完 synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。t2 线程这时才
可以进入 obj 房间,反锁住门,执行它的 count— 代码。

注意:上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1 对象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。**

2、可见性

2.1 退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

  1. static boolean run = true;
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread t = new Thread(()->{
  4. while(run){
  5. // ....
  6. }
  7. });
  8. t.start();
  9. Thread.sleep(1000);
  10. run = false; // 线程t不会如预想的停下来
  11. }

为什么呢?分析一下:
1、初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
image.png
2、因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
image.png
3、1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
image.png

2.2 解决方法

volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到 主存中获取它的值,线程操作 volatile 变量都是直接操作主存
volatile只能保证可见性,synchronized既可以保证可见性、也可以保证原子性

2.3 可见性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一 个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:上例从字节码理解是这样的:

  1. getstatic run // 线程 t 获取
  2. run true getstatic run // 线程 t 获取
  3. run true getstatic run // 线程 t 获取
  4. run true getstatic run // 线程 t 获取
  5. run true putstatic run // 线程 main 修改 run 为 false, 仅此一次
  6. getstatic run // 线程 t 获取 run false

比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i— ,只能保证看到最新值,不能解决指令交错

  1. getstatic i // 线程1-获取静态变量i的值 线程内i=0
  2. getstatic i // 线程2-获取静态变量i的值 线程内i=0
  3. iconst_1 // 线程1-准备常量1
  4. iadd // 线程1-自增 线程内i=1
  5. putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
  6. iconst_1 // 线程2-准备常量1
  7. isub // 线程2-自减 线程内i=-1
  8. putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

注意:
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

3、有序性

3.1 诡异的结果

  1. int num = 0;
  2. boolean ready = false;
  3. // 线程1 执行此方法
  4. public void actor1(I_Result r) {
  5. if(ready) {
  6. r.r1 = num + num;
  7. } else {
  8. r.r1 = 1;
  9. }
  10. }
  11. // 线程2 执行此方法
  12. public void actor2(I_Result r) {
  13. num = 2;
  14. ready = true;
  15. }

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
有同学这么分析:
情况1:线程1先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2先执行 num = 2,但没来得及执行ready = true,线程1 执行,还是进入else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
但我告诉你,结果还有可能是 0
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:
借助 java 并发压测工具 jcstress https://wiki.openjdk.java.net/display/CodeTools/jcstress

3.2 解决方法

volatile 修饰的变量,可以禁用指令重排

  1. @JCStressTest
  2. @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
  3. @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
  4. @State
  5. public class ConcurrencyTest {
  6. int num = 0;
  7. volatile boolean ready = false;
  8. @Actor
  9. public void actor1(I_Result r) {
  10. if(ready) {
  11. r.r1 = num + num;
  12. } else {
  13. r.r1 = 1;
  14. }
  15. }
  16. @Actor
  17. public void actor2(I_Result r) {
  18. num = 2;
  19. ready = true;
  20. }
  21. }

结果为:

  1. *** INTERESTING tests
  2. Some interesting behaviors observed. This is for the plain curiosity.
  3. 0 matching test results.

3.2 有序性理解

  1. static int i;
  2. static int j;
  3. // 在某个线程内执行如下赋值操作
  4. i = ...; // 较为耗时的操作
  5. j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是

  1. i = ...; // 较为耗时的操作
  2. j = ...;

也可以是

  1. j = ...;
  2. i = ...; // 较为耗时的操作

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性,例如著名的 double-checked
locking 模式实现单例
image.png
image.png

3.4 happens-before

happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
image.png
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
image.png
线程 start 前对变量的写,对该线程开始后对该变量的读可见
image.png
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或t1.join()等待它结束)
image.png
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
image.png
1)对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
2)具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
变量都是指成员变量或静态成员变量

4、CAS与原子类

4.1 CAS

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

  1. // 需要不断尝试
  2. while(true) {
  3. int 旧值 = 共享变量 ; // 比如拿到了当前值 0
  4. int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
  5. /*
  6. 这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
  7. compareAndSwap 返回 false,重新尝试,直到:
  8. compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
  9. */
  10. if( compareAndSwap ( 旧值, 结果 )) {
  11. // 成功,退出循环
  12. }
  13. }

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
1)因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
2)但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子

4.2 乐观锁与悲观锁

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

4.3 原子操作类

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

  1. // 创建原子整数对象
  2. private static AtomicInteger i = new AtomicInteger(0);
  3. public static void main(String[] args) throws InterruptedException {
  4. Thread t1 = new Thread(() -> {
  5. for (int j = 0; j < 5000; j++) {
  6. i.getAndIncrement(); // 获取并且自增 i++
  7. // i.incrementAndGet(); // 自增并且获取 ++i
  8. }
  9. });
  10. Thread t2 = new Thread(() -> {
  11. for (int j = 0; j < 5000; j++) {
  12. i.getAndDecrement(); // 获取并且自减 i--
  13. }
  14. });
  15. t1.start();
  16. t2.start();
  17. t1.join();
  18. t2.join();
  19. System.out.println(i);
  20. }

5、synchronized 优化

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

5.1 轻量级锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。这就好比:
学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。 如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
假设有两个方法同步块,利用同一个对象加锁

  1. static Object obj = new Object();
  2. public static void method1() {
  3. synchronized( obj ) {
  4. // 同步块 A
  5. method2();
  6. }
  7. }
  8. public static void method2() {
  9. synchronized( obj ) {
  10. // 同步块 B
  11. }
  12. }

每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
image.png

5.2 锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

  1. static Object obj = new Object();
  2. public static void method1() {
  3. synchronized( obj ) {
  4. // 同步块
  5. }
  6. }

image.png

5.3 重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
1)自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
2)好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
3)Java 7 之后不能控制是否开启自旋功能
自旋重试成功的情况
image.png
自旋重试失败的情况
image.png

5.4 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.
image.png
可以参考这篇论文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-
149958.pdf
假设有两个方法同步块,利用同一个对象加锁

  1. static Object obj = new Object();
  2. public static void method1() {
  3. synchronized( obj ) {
  4. // 同步块 A
  5. method2();
  6. }
  7. }
  8. public static void method2() {
  9. synchronized( obj ) {
  10. // 同步块 B
  11. }
  12. }

image.png

5.5 其它优化

5.5.1 减少上锁时间

同步代码块中尽量短

5.5.2 减少锁的粒度

将一个锁拆分为多个锁提高并发度,例如:
1)ConcurrentHashMap
2)LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
3)LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高

5.5.3. 锁粗化

多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

  1. new StringBuffer().append("a").append("b").append("c");

5.5.4 锁消除

JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。

5.5.5 读写分离

CopyOnWriteArrayList ConyOnWriteSet
参考:
https://wiki.openjdk.java.net/display/HotSpot/Synchronization
http://luojinping.com/2015/07/09/java锁优化/
https://www.infoq.cn/article/java-se-16-synchronized
https://www.jianshu.com/p/9932047a89be
https://www.cnblogs.com/sheeva/p/6366782.html
https://stackoverflflow.com/questions/46312817/does-java-ever-rebias-an-individual-lock