前言
目录
- 常见面试题
- 第二部分:自动内存管理
- 第2章:Java的内存区域
- 1. 程序计数器
- 1.1 概述
- 1.2 特点
- 2. Java虚拟机栈
- 2.1 概述
- 2.2 特点
- 2.3 知识补充
- 3. Java本地方法栈
- 3.1 概述
- 3.2 特点
- 4.Java堆
- 4.1 概述
- 4.2 特点
- 5. 方法区
- 5.1 概述
- 5.2 "去永久代"计划
- 5.3 内存诊断
- 错误信息
- 场景
- 5.4 运行时常量池
- 概念
- 字符串池:StringTable
- 5.5 直接内存
- 概念
- 分配和回收原理
- 第3章:JVM的回收机制
- 3.1 对象可回收性分析
- 1. 引用计数法
- 概念
- 优缺点
- 2. 可达性分析
- 概念
- 哪些对象可以作为GC Root呢?
- 3. 四种引用
- 1. 强引用
- 2. 软引用
- 3. 弱引用
- 4. 虚引用
- 5. 终结器引用
- 3.2 垃圾回收算法
- 1. 标记清除
- 概念
- 优缺点
- 2. 标记整理
- 概念
- 优缺点
- 3. 复制
- 概念
- 优缺点
- 4. 分代收集算法
- 3.3 分代垃圾
- 1. 分代收集理论
- 2. 分代回收实例
- 3. 相关的VM参数
- 3.4 垃圾回收器分类
- 1. 串行回收器
- 2. 吞吐量优先
- 3. 响应时间优先
- 3.5 垃圾收集器的前置知识点
- 1. 根节点枚举
- 2. 安全点
- 3. 安全区域
- 4. 记忆集和卡表
- 5. 写屏障
- 6. 并发的可达性分析
- 3.6 经典垃圾收集器
- 1. Serial 收集器
- 1. 特点
- 2. ParNew 收集器
- 1. 特点
- 3. Parallel Scavenge 收集器
- 1. 特点
- 2. VM相关参数
- 4. Serial Old 收集器
- 1. 特点
- 5. Parallel Old 收集器
- 1. 特点
- 6. CMS 收集器
- 1. 基本概念
- 2. 收集过程
- 3. 缺陷
- 7. Garbage First 收集器
- 1.基本概念
- 2. 收集过程
- 3. 缺陷
- 4. JDK 8 开始的不断优化
- 第三部分:虚拟机执行子系统
- 字节码的角度去分析 a++ 与 ++a的操作。
- 构造器
- 类构造器\<cinit\>()v
- 方法构造器\<init\>()v
- 多态的原理
- 练习:finally
- finally 出现了return
- finally 对返回值的影响
- 第7章:虚拟机的类加载机制
- 7.1 类加载的时机
- 7.2 类加载的过程
- 1. 加载
- 1)步骤
- 2)使用时机
- 2. 验证
- 3. 准备
- 4. 解析
- 5. 初始化
- 1)主动引用
- 2)被动引用
- 7.3 双亲委派机制
- 1. 代码演示
- 2. 打破双亲委派机制
- 7.4 自定义类加载器
- 7.5 沙箱安全机制
- 第四部分:程序编译与代码优化
- 第10章:前端编译与优化
- 10.1 泛型
- 10.2 自动装箱与自动拆箱
- 10.3 遍历循环
- 1) foreach 数组
- 2) foreach 集合
- 10.4 条件编译
- 10.5 默认的构造器
- 10.6 可变参数
- 10.7 switch 选择语句
- 1) switch字符串
- 2) switch枚举类
- 10.8 try-with-resource
- 10.9 方法重写时的桥接方法
- 10.10 匿名内部类
- 10.11 综合案例演示
- 第11章:后端编译与优化
- 11.1 分层编译
- 11.2 方法内联
- 11.3 字段优化
- 11.4 反射优化
- 第五部分:高效并发
- 第13章:线程的安全与锁的优化
- 13.2 线程安全
- 13.2.1 Java中的线程安全
- 1. 原子性
- 2. 可见性
- 3. 有序性
- 4. happens-before
- 13.3 线程安全的方法
- 1. CAS 即 Compare and Swap
- 2. 原子操作类
- 原子类的原理
- 缺点
- 13.3 锁优化
- 1. 轻量级锁
- 2. 锁膨胀
- 3. 重量级锁
- 4. 偏向锁
- 5. 其他优化
常见面试题
- 介绍下 Java 内存区域(运行时数据区)
- Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
- 对象的访问定位的两种方式(句柄和直接指针两种方式)
- 如何判断对象是否死亡(两种方法)。
- 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
- 如何判断一个常量是废弃常量
- 如何判断一个类是无用的类
- 垃圾收集有哪些算法,各自的特点?
- HotSpot 为什么要分为新生代和老年代?
- 常见的垃圾回收器有哪些?
- 介绍一下 CMS,G1 收集器。
- Minor Gc 和 Full GC 有什么不同呢?
第二部分:自动内存管理
第2章:Java的内存区域
又可称为 Java虚拟机运行时数据区 和 Java内存结构
1. 程序计数器
1.1 概述
通过改变当前程序计数器的值来选取下一条字节码指令。是字节码的行号指示器。
1.2 特点
- 线程私有
Java多线程是轮流使用处理器来实现,所以,为了能够切换后每个线程能够恢复到当前的执行位置。每条程序计数器都是私有的 - 唯一一个没有任何OOM情况的区域
2. Java虚拟机栈
2.1 概述
- Java方法执行的线程内存模型。
每个方法被调用至执行完毕的过程,就代表着栈帧在虚拟机栈中入栈到出栈的过程 - 每个虚拟机栈里面包含多个栈帧(方法1会间接调用方法2)。栈帧主要用于存储方法运行需要的数据:存储局部变量表、操作数栈、动态连接、方法出口等信息。
- 每个虚拟机栈里面只有一个活动栈帧。
2.2 特点
- 线程私有
- 内存 溢出异常 (OutOfMemoryError)和 栈溢出异常 (StackOverflowError)
2.3 知识补充
- 垃圾回收是否涉及虚拟机栈?
不是。因为方法调用结束后,栈帧会会从虚拟机栈被弹出。 - 栈内存是如何分配大小?是不是越大越好。
- 每个线程在创建的时候都会创建一个虚拟机栈,而物理内存是固定的,栈内存划分的越大, 可分配的线程数就越少。
- 栈内存是主要是用于方法执行的内存模型,这个值的变大就是意味着能放入更多的方法调用:递归调用方法。
3. Java本地方法栈
3.1 概述
本地(Native)方法执行的线程内存模型。
3.2 特点
- 线程私有
- 内存溢出异常 (OutOfMemoryError)和 栈溢出异常 (StackOverflowError)
4.Java堆
4.1 概述
主要就是存放对象实例。
所有线程共享的一块内存区域,在虚拟机启动时创建,
4.2 特点
- GC管理的内存区域;
- 线程共享,需要考虑一个线程安全性问题
- 内存溢出异常 (OutOfMemoryError)
5. 方法区
5.1 概述
逻辑上的区域。没有规定虚拟机产商具体实现的细节。
方法区主要用来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后的代码等数据。
5.2 "去永久代"计划
- 实现细节:
- 在 jdk6 以前,方法区是集成在JVM里面,使用永久代来实现;
- 在 jdk7 的时候,就将永久代的字符串常量池,静态变量等移植到堆里面;
- 在 jdk8 以后,方法区完全废弃永久代的概念,是本地内存中实现的元空间;
5.3 内存诊断
错误信息
方法区的OOM,一般都是取决于自己的物理内存。
// jdk1.8之前, OutOfMemoryError:PermGen space
-xx:MaxPermSize=8m // 最大永久代大小
// 在jdk1.8之后, OutOfMemoryError:Metaspace
-xx:MaxMetaspzceSize=8m
场景
- spring
- mybatis
5.4 运行时常量池
Class文件的常量池是什么?就是一张表,虚拟机指令会根据这张常量表去找到要执行的类名,方法名、参数类型、字面量等信息。(解释出来的信息放在磁盘里面?)
概念
用于存放静态编译产生的字面量和符号引用。
运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
该常量池相比于Class文件常量池,它具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。(String类的intern() 方法)
字符串池:StringTable
public static void main(String[] args) throws InterruptedException {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4); // f
System.out.println(s3 == s5); // t
System.out.println(s3 == s6); // t
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢 (我也不知道这个答案了,下一次再补充
System.out.println(x1 == x2); // f
}
特性
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是 StringBuilder (1.8)
- 字符串常量拼接的原理是编译期优化
- 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
- 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
- 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
优化
- 调整 -XX:StringTableSize=桶个数
- 考虑将字符串对象是否入池
5.5 直接内存
概念
- 不接受jvm的内存回收管理;jvm的垃圾回收只能释放java内存。
- 常见于NIO操作,用于数据缓存区;(文件读写操作)
- 分配回收成本比较高,但读写性能高
分配和回收原理
直接内存的释放,是要调用unsafe来释放内存。调用直接内存unsafe对象的freeMemory()方法,借助了虚引用的机制。
ByteBuffffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffffer 对象,一旦ByteBuffffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存
第3章:JVM的回收机制
3.1 对象可回收性分析
1. 引用计数法
概念
在对象中添加一个引用计数器,每当有一个对象引用他的时候,计数器就加一;当引用失效的时候,引用计数器就减一。当计数器为零的对象就是表示需要被回收的对象。
优缺点
优点:原理简单,判断效率高
缺点:需要大量的额外操作才能够使得正确工作,如对象之间的相互循环引用问题。
2. 可达性分析
概念
如果对象到GC Root有引用链相连,则表示这个对象是存活的。
哪些对象可以作为GC Root呢?
- 虚拟机栈中的局部变量表引用的对象,如:正在运行中方法所调用的变量
- 方法区中类静态属性引用(引用类型静态变量)和常量引用对象(字符串常量池中的引用)
- 本地方法栈中的引用的对象
3. 四种引用
前言
谈及这个呢,就是无论是可达性分析还是引用计数法,对象的存活都离不开引用。
1. 强引用
类似于 new Object()
的引用关系。
只有要强引用关系存在,就不会被回收。
2. 软引用
概念
- 垃圾回收以后,内存不足,则尝试将软引用对象消除。释放后不够,就会内存溢出异常。
- 弱引用自身的释放可以配合引用队列
实践
图片读取以及存取
3. 弱引用
概念
- 无论内存是否足够,垃圾回收后,弱引用对象消除。但不是把堆中所有弱引用全部回收,回收到足够放下接下来的数据即可。
- 配合引用队列来释放弱引用自身
实践
ThreadLocal变量。
而这个变量就是使用在Spring的@Transaction注解里面。
4. 虚引用
概念
- 必须配合引用队列来释放引用
- 主要是配合ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由Reference Handler 线程调用虚引用相关方法来释放直接内存
实践
主要就是用来标记某样东西用来回收的时候能够收到通知。
5. 终结器引用
(四大引用主要就是上面四种,谈及这个的话。就当拓展内容吧)
- 无需手动编码,但其内部配合引用队列来使用。在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer线程通过终结器引用找到被引用对象并调用他的 finalize()方法,第二次GC才能够回收被引用对象。
3.2 垃圾回收算法
1. 标记清除
概念
标记:没有被GC Root引用的对象
清除:垃圾对象占用的空间清除。
对象占用内存的起始以及结束地址记录下来,当到一个空闲地址链表里面。下次有新对象的时候,就去空闲地址链表里面查看。
优缺点
优点:清理速度快
缺点:内存碎片化
2. 标记整理
概念
标记:没有被GC Root引用的对象
整理:内存紧凑
优缺点
与标记清除算法相反。
3. 复制
概念
可用内存区域划分为大小相等的两块:FROM 以及 TO;
首先标记不被引用的对象;
把FROM区域引用的对象复制到TO区域;清空FROM区。
并且交换FROM区以及TO区:把TO区域改为FROM区,FROM区改为TO区。
哪一个区域为空,哪个区域就是TO区。
优缺点
优点:不会产生碎片
缺点:可用内存变小,需要占用双倍内存空间
4. 分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
延伸面试问题: HotSpot 为什么要分为新生代和老年代?
根据上面的对分代收集算法的介绍回答。
3.3 分代垃圾
1. 分代收集理论
收集器应该将Java堆划分为不同区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集对象过程的次数)分配到不同区域之中存储。
2. 分代回收实例
- 首先,会先判断对象的大小。
- 如果对象在伊甸园区域能够放下,
- 将对象首先分配在伊甸园区;
- 新生代中伊甸园,空间不足的时候,就会触发Minor GC。伊甸园Eden Space 和 幸存区From 存活的对象使用copy 到幸存区to中,存活的对象年龄加1,并且交换 幸存区To 和 幸存区From
- Minor GC 会引发 stop the world:暂停其他用户线程:涉及到对象的拷贝,会让垃圾回收线程先工作完,再恢复其他的线程。
- 当对象寿命超过阈值时,会晋升到老年代,最大寿命是15。
- 当老年代空间不足时,会尝试Minor GC。如果没有解决问题,那么就会触发Full GC。
- Full GC 也会引发 stop the world ,耗时更长。
- 如果大对象在伊甸园区域肯定放不下,那么就不会触发新生代的Minor GC,直接内存担保进入了 老年代里面。
- 当主线程里面的其中一个子线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行
3. 相关的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.4 垃圾回收器分类
1. 串行回收器
- 垃圾回收线程是单线程
2. 吞吐量优先
- 黑马程序员:吞吐量优先…
- 垃圾回收线程是多线程
- 堆内存较大,多核 cpu
- 工作时间占比高:在一定时间内的,STW的次数最少
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
- 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4,垃圾回收时间占比最低,这样就称吞吐量高
3. 响应时间优先
- 黑马程序员:响应时间优先…
- 垃圾回收线程是多线程
- 工作反应速度快:保证其他用户线程每一次等待STW的时间短:1s内,进行5次STW,每次花费0.1秒。0.1 0.1 0.1 0.1 0.1 = 0.5
- 响应时间优先目的就是减少每次STW的间隔时间。
它只有初始标记STW了,而且只找GC ROOTS,相比并行减少了时间,并发标记的时候进行可达性分析,不过不会STW,重新标记主要是看在并发标记时间是否修改
然后进行并发清理,如果并发标记时使用或者修改了可达性的对象的信息,就在重新标记处改成正确的,最后进行并发清理,整个过程只有初始标记STW了,也是响应时间优先的体现,尽可能减少STW的时间。
3.5 垃圾收集器的前置知识点
1. 根节点枚举
概念
从GC Root开始遍历对象,查看对象的引用。
特点
必须暂停用户线程
技术优化
在 HotSpot 里面,有一个OopMap的数据结构存储对象的引用。
在特定的位置(安全点)记录下栈里和寄存器里哪些位置是引用。
收集器就可以直接扫描这个OopMap数据结构,而不需要不断地从方法区等GC Roots开始查找。
2. 安全点
前置条件
引用关系会可能变化 -> 安全点
概念
程序执行时,记录下栈里和寄存器里哪些位置是引用。
用户程序执行时强制要求必须执行到达安全点后才能够暂停。
具体实现
- 抢先式中断
- 不需要线程的执行代码主动去配合。
- 在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
- 落魄了,家人们
- 主动式中断
- 当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位。
- 安全点轮询:各个线程执行过程时会不停地主动去轮询这个标志,
- 触发线程中断:一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
3. 安全区域
前置条件
程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间。
典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求。
概念
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。
具体实现
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。
发起垃圾收集的时候,会忽略安全区的代码。
用户线程将要走出安全区的时候,必须完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的
阶段)。
4. 记忆集和卡表
记忆集为解决对象跨代引用所带来的问题,用以避免把整个老年代加进GC Roots扫描范围。
记忆集
抽象概念。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
卡表
卡表就是记忆集的一种具体实现。
卡表最简单的形式可以只是一个字节数组。
卡页就是卡表里面的每一个元素。元素都对应着其标识的内存区域中一块特定大小的内存块
具体实现
一个卡页的内存中通常包含不止一个对象。
只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
5. 写屏障
前置条件
解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
概念
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知。
在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。
直至G1收集器出现之前,其他收集器都只用到了写后屏障。
缺点
写屏障的开销
卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。避免伪共享问题:-XX:+UseCondCardMark,用来决定是否开启。卡表更新的条件判断。开启会增加一次额外判断的开销
6. 并发的可达性分析
三色标记(Tri-color Marking)[1]作为工具来辅助推导。
当且仅当以下两个条件同时满足,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
增量更新(Incremental Update)
破坏的是第一个条件。黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照(Snapshot At The Beginning,SATB)
破坏的是第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
3.6 经典垃圾收集器
新生代收集器
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)。
1. Serial 收集器
1. 特点
- 单线程 收集器
注:这里指的 “单线程” 指的就是在垃圾收集的时候,就只有GC线程在工作,所有其他的工作线程必须暂停等待其工作结束以后 - 新生代 收集器
- 复制算法
2. ParNew 收集器
1. 特点
- 多线程 并行 收集器
- 新生代 收集器
- 回收算法、回收策略、VM参数等都与 Serial 收集器一样
“并发” 与 “并行” 收集器概念:
- “并发”(Parallel):多个垃圾收集器线程之间的关系:多条垃圾收集器线程同时工作,用户线程是处于等待状态。
- “并发”(Concurrent):垃圾收集器线程与用户线程之间的关系:同一时间,垃圾收集器线程与用户线程(吞吐量会受到影响)同时工作。
3. Parallel Scavenge 收集器
1. 特点
- 多线程 并行 收集器
- 新生代 收集器
- 标记 - 复制 算法
- 吞吐量优先 收集器
- JDK 1.4.0 中已经存在
2. VM相关参数
- 控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis
- 不能越低越好,因为是牺牲吞吐量以及新生代的空间代价来换取的
- 设置吞吐量大小:-XX:GCTimeRatio
- 垃圾收集时间占总时间的比率:譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值参数为99。
- 自适应的调节策略:-XX:+UseAdaptiveSizePolicy
- 人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了
- 只需要设置:基本的内存数据(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或 -XX:GCTimeRatio(更关注吞吐量)参数
老年代收集器
4. Serial Old 收集器
1. 特点
- 是Serial收集器的老年代版本
- 单线程 收集器
- 老年代 收集器
- 标记 - 整理算法
5. Parallel Old 收集器
1. 特点
- 是Parallel Scavenge 收集器的老年代版本
- 多线程 并行 收集器
- “吞吐量优先” 收集器
- JDK 6时才开始提供的
- 标记 - 整理算法
6. CMS 收集器
1. 基本概念
- 多线程 并发 收集器;
- 标记 - 清除算法
- "响应时间优先"收集器
- JDK5 发布时出现;JDK 9开始,官方推荐的ParNew + CMS 收集器组合被 G1收集器 所取代;
- CMS收集器采用增量更新算法实现,在并发标记阶段保证收集线程与用户线程互不干扰地运行
2. 收集过程
- 初始化标记(完全暂停用户线程)
标记一下GCRoots能直接关联到的对象 - 并发标记
从GC Roots的直接关联对象开始遍历整个对象图的过程 - 重新标记(完全暂停用户线程)
为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录 - 并发清除
清理删除掉标记阶段判断的已经死亡的对象
3. 缺陷
- 对处理器资源非常敏感
CMS默认启动的回收线程数是(处理器核心数量+3)/4。如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。 - 无法处理 “浮动垃圾” ,有可能出现 “Concurrent Mode Failure” 失败进而导致另一次完全的 STW 的 Full GC。
“浮动垃圾”:在CMS阶段并发标记以及并发清除,由于用户线程还在运行,仍然会有浮动垃圾产生,这部分垃圾只能够等待下一次垃圾收集时再进行处理。这部分的垃圾就是被称为:“浮动垃圾”。 - 内存空间碎片化
不分新生代以及老年代收集器了
7. Garbage First 收集器
1.基本概念
- 注重吞吐量以及低延迟,停顿预测模型,默认的收集停顿目标为200ms(-xx:MaxGCPauseMillis=time)。
- 收集器面向局部收集的设计思路和基于Region的内存布局形式。超大堆内存,会将堆划分为多个大小相等的Region(-xx:G1HeapRegionSize=size,取值范围为1MB~32MB,值应为2的N次幂)
- 将堆内存划为为大小相等的独立区域(Region)
- 如果某一个对象超过了 Region 的一半大小,那么就会放到Humongous区域,专门用来存储大对象。
- 整体是 标记- 整理 算法,堆划分后的区域之间是复制算法
- “响应时间优先” 收集器
- 而G1收集器则是通过原始快照(SATB)算法来实现,在并发标记阶段保证收集线程与用户线程互不干扰地运行
2. 收集过程
- 初始标记(完全暂停用户线程)
标记GC Roots能直接关联到的对象,并且修改TAMS指针的值。(让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。)
(G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。) - 并发标记
递归扫描整个堆对象图里面被GC Root引用的。重新处理SATB记录下的在并发时有引用变动的对象。
必须在堆空间占满之前完成,否则会变为Full GC。 - 最终标记(完全暂停用户线程)
处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。 - 筛选回收(完全暂停用户线程)
根据用户所期望的停顿时间来制定回收计划,并实施计划。
G1 收集器会安排一张垃圾堆积“价值”列表,根据用户设置的允许收集停顿时间,优先处理回收价值比较高的区域。
3. 缺陷
- 更高的内存占用负担。
- Region数量比传统收集器的分代数量明显要多得多
- 记忆集(解决Region里面存在的跨Region引用对象)的应用比较复杂:
堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用。 - 执行负载大。
- CMS用写后屏障来更新维护卡表,写屏障实现是直接的同步操作;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况,实现是类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
4. JDK 8 开始的不断优化
JDK 8u20 字符串去重
- 优点:节省大量内存
- 缺点:略微多占用了CPU时间,新生代回收时间略微增加
- -xx:+UseStringDeduplication
String str1 = new String("hello"); // char[]{'h','e','l','l','o'}
String str1 = new String("hello"); // char[]{'h','e','l','l','o'}
- 将所有新分配的字符串放入一个队列
- 当新生代在回收的时候,就会将字符串检测是否重复
- 如果他们俩的值是一样的,则让他们引用同一个 char[]
JDK 8u20 并发标记类卸载
- 所有对象都经过并发标记后,就能知道哪些类不在被使用。
- 当一个类加载器的所有类都不在使用,则卸载该 对象所加载所有类。
- -xx:+ClassUnloadingWithConCurrentMark 默认开启
JDK 8u20 回收巨型对象
- G1 不会对巨型对象进行拷贝和赋值,用完了就会在新生代回收的时候进行处理。
JDK 9 并发标记起始时间的调整
- JDK 9之前需要使用 -xx:InitiatingHeapOCccupancyPercent 来指定大小
- JDK 9 之后,可以动态的调节了
- -xx:InitiatingHeapOccupancyPercent 来设置初始大小
- G1收集器会根据数据采样进行动态调整
总结:G1收集器常 与 CMS收集器 的 互相比较
相比较,CMS收集器,G1优势就有很多:分Region的内存布局、指定最大的停顿时间、垃圾回收算法等;
在用户程序运行时的负载上看,G!收集器负载较大,耗损性能较少
在内存占用来说,G1的记忆集内存占用率相比较而言较大。
总之,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势。
第三部分:虚拟机执行子系统
字节码的角度去分析 a++ 与 ++a的操作。
public class Test {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--; // 10+12+12
// int b = ++a + a++ + a--; //11+11+12
System.out.println(a); //11
System.out.println(b); //34
System.out.println();
int c = 1;
System.out.println(c++); //1 先在操作数栈中,加载c。在执行下一步取c的操作之前,局部变量表中的c都不会改变
c = c++; // 再去拿c的时候,在局部变量表中c就是变为了2。
c = c++;
c = c++;
System.out.println(c); //2
System.out.println(++c); //3
System.out.println(c--); //3
System.out.println(--c); //1
System.out.println();
int d = 1;
System.out.println(d++); //1
System.out.println(d); //2
int e = d+1;
System.out.println(e); //3
System.out.println(d); //2
System.out.println();
}
}
分析:
int a = 10;
int b = a++ + ++a + a--; // 10+12+12
// int b = ++a + a++ + a--; //11+11+12
System.out.println(a); //11
System.out.println(b); //34
System.out.println();
- 注意 iinc 指令是直接在局部变量 slot 上进行运算
- a++ 和 ++a 的区别是先执行 iload 还是 先执行 iinc
执行赋值语句:10 赋值给 a;
a++ 开始执行:先 iload 加载a到,再 iinc 在局部变量 slot 自增,此刻a变为了11
++a :先 iinc 局部变量 slot里面自增, 再 iload a
a-- : 先 iload a , 再 iinc 局部变量 slot里面自增 -1
此时,a的值为11,操作数栈里面为34
把 34 赋值给 b
拓展
public class Demo {
public static void main(String[] args) {
int i = 0;
int x = 0;
while (i < 10) {
x = x++;
i++;
}
System.out.println(x); // 0
}
}
构造器
类构造器<cinit>()v
public class Demo {
static int i = 10;
static {
i = 70;
}
static {
i = 30;
}
public static void main(String[] args) { }
}
整个类的构造方法?(表示存疑,个人觉得就是静态方法以及静态变量的收集。
编译器会从上到下的顺序,收集所有static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法<cinit>()v
<cinit>()v会在类加载的初始化阶段被调用。
方法构造器<init>()v
public class Demo {
private String a = "s1";
{ b = 20; }
private int b = 10;
{ a = "s2"; }
public Demo(String a , int b){
this.a = a;
this.b = b;
}
public static void main(String[] args) {
Demo demo = new Demo("s3", 30);
System.out.println(demo.a); // s3
System.out.println(demo.b); // 30
}
}
编译器会按从上之下的顺序,收集所有的 {} 代码块和成员变量赋值的代码,形成新的构造方法。但是原始构造方法内的代码总是会在最后。
多态的原理
public class Test {
public static void demo(Animal animal){
animal.eat();
System.out.println(animal.toString());
}
public static void main(String[] args) throws IOException {
demo(new Cat());
demo(new Dog());
System.in.read();
}
}
abstract class Animal{
public abstract void eat();
@Override
public String toString() {
return "我是" + this.getClass().getSimpleName();
}
}
class Dog extends Animal{
@Override
public void eat() {
System.out.println("啃骨头");
}
}
class Cat extends Animal{
@Override
public void eat() {
System.out.println("吃鱼");
}
}
显示的结果为
吃鱼
我是Cat
啃骨头
我是Dog
练习:finally
finally 出现了return
public class Demo {
public static void main(String[] args) {
int result = test();
// 20
System.out.println(result);
}
public static int test() {
try {
return 10;
} finally {
return 20;
}
}
}
字节码显示:
由于 finally 中的return 被插入了所有的可能的流程,因此返回结果肯定以 finally 为准;
Code:
stack=1, locals=2, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> slot 0 (从栈顶中移除)
3: bipush 20 // <- 20 放入栈顶
5: ireturn // 返回栈顶 int(20)
6: astore_1 // catch any -> slot 1
7: bipush 20 // <- 20 放入栈顶
9: ireturn // 返回栈顶 int(20)
Exception table:
from to target type
0 3 6 any
- 如果finally 中出现了 return,会吞掉try 语句块的异常
public class Demo {
public static void main(String[] args) {
int result = test();
// 20
System.out.println(result);
}
public static int test() {
try {
int i = 1/0;
return 10;
} finally {
return 20;
}
}
}
finally 对返回值的影响
finally对返回值进行修改,是不会改动返回的值的。
public class Demo {
public static void main(String[] args) {
int result = test();
// 10
System.out.println(result);
}
public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}
第7章:虚拟机的类加载机制
7.1 类加载的时机
类的生命周期
其中,加载、验证、准备、初始化、卸载这五个阶段是顺序是确认。
而解析阶段的顺序有可能在初始化之后开始,为了支持Java语言的动态绑定。
7.2 类加载的过程
类记载的过程主要就是包括:加载、验证、准备、解析、初始化。
1. 加载
1)步骤
在类加载阶段是在外部虚拟机去实现,它需要完成的三件事:
- 通过类的全限定名来获取此类的二进制字节流
- 把字节流代表的静态存储结构转化为方法取得运行时数据接口
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口
2)使用时机
类加载的时机取决于虚拟机自己的实现。
2. 验证
确保Class文件字节流中的信息符合全部约束以及要求
3. 准备
将类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量的初始值阶段。
初始值在“通常情况”下是为数据类型的零值。
① 比如一个类中变量修饰 static 为:准备阶段过后的初始值是为0
public static int value = 123;
把value赋值为123是在类的初始化阶段才会被执行。
② 如果一个类中变量定义 static final (基本类型/字符串) 为:准备阶段就会将值赋值为123。
public static final int value = 123;
③ 如果一个类中变量定义 static final 引用类型 为:准备阶段就会将值赋值为null。
public static final Object object = new Object;
把赋值是在类的初始化阶段才会被执行。
4. 解析
将常量池中的符号引用转化为直接引用。
- 符号引用(Symbolic References):
- 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
- 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
5. 初始化
执行类构造器中的 () 方法,由javac 编译器自动生成。线程安全的。
类初始化的时机有且只有这六种情况。这六种场景称为类的主动引用。
1)主动引用
- new和首次访问这个类的静态变量和静态方法:有new、getstatic、putstatic或invokestatic这四条字节码指令且类型没有进行初始化;
- 使用new关键字来实例化对象
- 读取或设置一个类型的静态字段(被final修饰,已在编译期间把结果放入常量池的静态字段除外)
- 调用一个类型的静态方法
- Class.forName:对类型进行反射调用且类没有被初始化过
- 初始化子类时,发现其父类没有初始化过,则会先触发父类的初始化
- main 方法所在类: 一个要执行的主类(包含main()方法的那个类)
- 使用JDK7新加入的动态语言的支持,如果一个java.lang.invoke.MethodHandle实例最后的解析的结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvkoeSpecial四种类型的方法句柄且句柄对应的类没有初始化
- 接口中定义了JDK8新加入的默认方法(被Default关键字修饰的接口方法),该接口的实现类发生了初始化,那么接口也需要初始化(并不要求其父接口全部都完成了初始化,只有正在使用到父类接口的时候(如:引用接口中定义的常量)才会初始化,)
2)被动引用
其他的情况,所有引用类型的方式都不会触发初始化,成为被动引用。
- 访问类的static final (基本类型和字符串) 不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化: new B[0];
- 类加载器的loadClass方法
- Class.forName 的参数2为 false的时候
- 类或接口没有静态语句块,
- 没有类变量的赋值操作
三个被动引用的例子。
被动引用案例一:通过子类引用父类的静态字段,不会导致子类初始化
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
*/
public class SuperClass {
static {
System.out.println("SuperClass init");
}
public static int value = 123;
}
public class SubClass extends SuperClass{
static {
System.out.println("SubClass init");
}
}
public class NotInitializationDemo1 {
public static void main(String[] args) {
/**
* 输出结果:
* SuperClass init
* 123
* 没有触发子类的初始化
*/
System.out.println(SubClass.value);
}
}
个人理解:父类静态字段的引用,就符合主动引用的情况一,那么就会触发父类的初始化而不会出发子类的初始化;至于是否要触发子类的加载验证,不同的虚拟机不同实现。在HotSpot虚拟机中,可以通过-XX:+TraceClassLoading参数观察到此操作会触发子类的加载。
被动引用案例二:通过数组定义来引用类,不会触发此类的初始化
/**
* 被动使用类字段演示二:
* 通过数组定义来引用类,不会触发此类的初始化
**/
public class NotInitializationDemo2 {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
/**
* 输出结果: [Lcom.hanliy.jvm.classLoader.passiveReference.SuperClass;@6ed3ef1
* 没有输出: “SuperClass init!”,说明没有SuperClass的初始化阶段
*/
System.out.println(superClasses);
}
}
书中解释:对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令anewarry出发。
被动引用案例三:调用类的常量
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
**/
public class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLO_WORLD = "hello world";
}
public class NotInitializationDemo3 {
public static void main(String[] args) {
/**
* 输出结果:hello world
* 没有触发类的初始化
*/
System.out.println(ConstClass.HELLO_WORLD);
}
}
符合主动引用中情况一,第二种代码实现的除外情况。
在编译期间就已经把 ConstClass 类中的常量存储在常量池中,以后 NotInitializationDemo3 对常量的引用都转自了自身常量池中的引用。
也就是,NotInitializationDemo3 没有 ConstClass 的符号引用入口。
具体的代码实现可以参考:类加载的初始化代码讲解
- 自动收集类中的所有类变量的赋值动作以及静态代码块中的语句产生。
案例演示
public class Load4 {
public static void main(String[] args) {
System.out.println(E.a);
System.out.println(E.b);
System.out.println(E.c);
/**
* 运行结果:
* 10
* hello
* init
* 20
*
* 2022/11/3 11:35
*/
}
}
class E {
public static final int a = 10;
public static final String b = "hello";
public static final Integer c = 20;
static {
System.out.println("init ");
}
}
7.3 双亲委派机制
它第一遍只是检查有没有加载,
第二遍是检查自己加载的包下有没有,有就加载,没有就让子加载器加载
1. 代码演示
public class JvmClassLoader {
public static void main(String[] args) {
Car car1 = new Car();
Car car2 = new Car();
Car car3 = new Car();
// car1在栈中的地址是:460141958
System.out.println("car1在栈中的地址是:" + car1.hashCode());
// car2在栈中的地址是:1163157884
System.out.println("car2在栈中的地址是:" + car2.hashCode());
// car3在栈中的地址是:1956725890
System.out.println("car3在栈中的地址是:" + car3.hashCode());
Class<? extends Car> aClass1 = car1.getClass();
Class<? extends Car> aClass2 = car2.getClass();
Class<? extends Car> aClass3 = car3.getClass();
// car1的class模板类是:class com.hanliy.jvm.Car
System.out.println("car1的class模板类是:" + aClass1);
// car2的class模板类是:class com.hanliy.jvm.Car
System.out.println("car2的class模板类是:" + aClass2);
// car3的class模板类是:class com.hanliy.jvm.Car
System.out.println("car3的class模板类是:" + aClass3);
ClassLoader classLoader = aClass1.getClassLoader();
// 当前的类加载是 —> 应用程序类加载器 :sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println("当前的类加载是:" + classLoader);
// 当前的父加载器 —> 扩展类加载器:sun.misc.Launcher$ExtClassLoader@1540e19d
System.out.println("当前的父加载器:" + classLoader.getParent());
// 当前的父加载器的父加载器 -> 根加载器 :null
System.out.println("当前的父加载器的父加载器:" + classLoader.getParent().getParent());
}
}
默认是使用本来的加载器加载依赖类的
由于JDBC在核心类库中,它由启动类加载器加载,由于驱动是在他的类初始化方法中加载的。所以驱动是DriverManager的依赖
默认是由启动类加载器加载,但找不到,不可能加载到驱动
于是要显示的调用Classd的forName方法使用一个能加载驱动的加载器加载驱动
2. 打破双亲委派机制
- JDBC
- Servlet 初始化器
- Spring 容器
- Dubbo(对 SPI 进行了扩展)
7.4 自定义类加载器
1)想加载非 classpath 随意路径中的类文件
2)都是通过接口来使用实现,希望解耦时,常用在框架设计
3)这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
7.5 沙箱安全机制
- 字节码校验器:
确保Java类文件遵循java语言规范。这样可以帮助Java实现内存保护。但并不是所有类文件都会经过字节码校验,比如核心类。 - 类装载器:在三个方面对沙箱起作用
1.防止恶意代码去干涉善意代码(双亲委派机制)
2.守护了被信任的类库边界
3.它将代码归入保护域,确定了代码可以执行哪些操作
第四部分:程序编译与代码优化
前端编译器(叫“编译器的前端”更准确一些):JDK的Javac,把 *.java 文件转变成 *.class 文件的过程。
后端编译器:
- 即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;
- 静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。
第10章:前端编译与优化
在 *.java 文件转变成 *.class 文件的过程中,class文件的优化。
10.1 泛型
Java中的 “泛型”:“类型擦除式泛型”,对于运行期的Java语言来说,ArrayList<int> 与 ArrayList<String> 其实是同一个类型。
10.2 自动装箱与自动拆箱
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); // true
System.out.println(e == f); // false
// 包装类的“==”运算在遇到算术运算的情况下会自动拆箱
System.out.println(c == (a + b)); // true
System.out.println(c.equals(a + b)); // true
// 包装类的 “equals()”方法不处理数据转型的关系
System.out.println(g == (a + b)); // true
System.out.println(g.equals(a + b)); // false
}
- 包装类的“==”运算在遇到算术运算的情况下会自动拆箱
- 包装类的 “equals()”方法不处理数据转型的关系
public static void main(String[] args) {
int a = 1;
int b = 100;
Integer a1 = 1;
Integer b1 = 100;
Integer a2 = new Integer(1);
Integer b2 = new Integer(100);
System.out.println(a == a1); // true
System.out.println(a == a2); // true
System.out.println(a1 == a2); // false
System.out.println(b == b1); // true
System.out.println(b == b2); // true
System.out.println(b1 == b2); // false
}
10.3 遍历循环
1) foreach 数组
相当于使用的是 for (int i = 0; i < ; i++) {}。
实例代码如下:
public class Demo {
public static void main(String[] args) {
int[] arrays = {1, 2, 3, 4, 5, 6, 7};
for (int array: arrays) {
System.out.println(array);
}
}
}
优化后的代码
public class Demo {
public Demo() {
}
public static void main(String[] args) {
// 语法糖1
int[] arrays = new int[]{1, 2, 3, 4, 5, 6, 7};
// 语法糖2
for(int var4 = 0; var4 < arrays.length; ++var4) {
int array = arrays[var4];
System.out.println(array);
}
}
}
2) foreach 集合
相当于迭代器。
示例代码如下:
public class Demo {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,3,4,5);
for (int x: list) {
System.out.println(x);
}
}
}
优化后的代码
public class Demo {
public Demo() {
}
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 3, 4, 5);
Iterator var2 = list.iterator();
while(var2.hasNext()) {
int x = (Integer)var2.next();
System.out.println(x);
}
}
}
10.4 条件编译
使用条件:常量的 if 语句。
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
该代码编译后Class文件的反编译结果:
public static void main(String[] args) {
System.out.println("block 1");
}
10.5 默认的构造器
在java文件中,如果当前类使用的是默认的构造器
public class Demo {
}
那么,语法糖就会自动调用父类的构造器
public class Demo {
public Demo() {
super();
}
}
10.6 可变参数
在变长参数出现之前,程序员也就是使用数组来完成类似功能的。
public class Demo {
public static void foo(String... args){
String[] array = args;
System.out.println(array);
}
public static void main(String[] args) {
foo("hello", "world");
}
}
可变参数 String… args ,其实是一个 String[] args。
public class Demo {
public static void foo(String... args){
String[] array = args;
System.out.println(array);
}
public static void main(String[] args) {
foo("hello", "world");
}
}
注意:当调用的是 foo() ,则编译期间就是 foo(new String[])。相当于创建了一个空数组。
10.7 switch 选择语句
1) switch字符串
public class Demo {
public static void choose(String str){
switch (str){
case "hello":{
System.out.println("你好");
break;
}
case "world" : {
System.out.println("世界");
break;
}
}
}
public static void main(String[] args) {
choose("hello");
}
}
优化后的代码
public class Demo {
public Demo() {
}
public static void choose(String str) {
byte var2 = -1;
switch(str.hashCode()) {
case 99162322: // hello 的 hashcode
if (str.equals("hello")) {
var2 = 0;
}
break;
case 113318802: // world 的 hashcode
if (str.equals("world")) {
var2 = 1;
}
}
switch(var2) {
case 0:
System.out.println("你好");
break;
case 1:
System.out.println("世界");
}
}
public static void main(String[] args) {
choose("hello");
}
}
第一条选择语句swtich,为什么使用了 hashcode 和 equals 方法:
用hashcode来提交效率,可以较少比较;
但是使用hashcode 会发生哈希冲突,所以,得使用 equals 来进一步避免哈希冲突
2) switch枚举类
switch 枚举的例子,原始代码:
public enum Sex {
MALE, FEMALE
}
public class Demo {
public static void choose(Sex sex) {
switch (sex) {
case MALE: {
System.out.println("男");
break;
}
case FEMALE: {
System.out.println("女");
break;
}
}
}
public static void main(String[] args) {
choose(Sex.MALE);
}
}
优化后的代码:
10.8 try-with-resource
jdk7开始,主要用于对需要关闭的资源处理,可以不用写finally 语句块,编译器会帮助关闭资源。
try (资源变量 = 创建资源变量) {
System.out.println(inputStream);
} catch () {
}
资源对象必须要实现 AutoCloseable接口,才能使用 try-with-resource。就可以不用写finally 语句块,编译器会帮助关闭资源。
实例1:
public class Demo {
public static void main(String[] args) {
try (InputStream inputStream = new FileInputStream("E:\\1.txt")) {
System.out.println(inputStream);
} catch (IOException e) {
e.printStackTrace();
}
}
}
优化后的代码如下:
public class Demo {
public Demo() {
}
public static void main(String[] args) {
try {
InputStream inputStream = new FileInputStream("E:\\1.txt");
Throwable var2 = null;
try {
System.out.println(inputStream);
} catch (Throwable var12) {
// var12 是代码出现的异常
var2 = var12;
throw var12;
} finally {
// 资源不为空
if (inputStream != null) {
// 代码出现异常
if (var2 != null) {
try {
// 关闭资源
inputStream.close();
} catch (Throwable var11) {
// 关闭资源异常,则作为被压制异常添加,用于防止信息丢失
var2.addSuppressed(var11);
}
}
// 代码没出现异常
else {
// 关闭资源
inputStream.close();
}
}
}
} catch (IOException var14) {
var14.printStackTrace();
}
}
}
实例2:
public class Demo {
public static void main(String[] args) {
try (MyResource myResource = new MyResource()) {
int i = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyResource implements AutoCloseable {
@Override
public void close() throws Exception {
throw new Exception("close 异常");
}
}
输出:
java.lang.ArithmeticException: / by zero
at linkedList.Demo.main(Demo.java:11)
Suppressed: java.lang.Exception: close 异常
at linkedList.MyResource.close(Demo.java:21)
at linkedList.Demo.main(Demo.java:12)
10.9 方法重写时的桥接方法
方法重写对返回值有两种情况:
- 父子类的返回值完全一致
- 子类返回值可以是父类返回值的子类
public class A {
public Number number(){
return 1;
}
}
class B extends A {
// 子类number方法的返回值是Integer 是父类number方法返回值Number的子类
@Override
public Integer number() {
return 2;
}
public static void main(String[] args) {
for (Method method : B.class.getDeclaredMethods()){
System.out.println(method);
}
/**
* 运行结果:
* public java.lang.Number jvm.B.number()
* public java.lang.Integer jvm.B.number()
* public static void jvm.B.main(java.lang.String[])
*
* 2022/11/3 9:34
*/
}
}
10.10 匿名内部类
public class Cindy11 {
public static void test(int x){
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok" + x);
}
};
}
}
优化后的代码
final class Cindy11$1 implements Runnale{
int val$x;
Cindy11$1(int x){
this.val$x = x;
}
public void run(){
System.out.println("ok" + this.val$x);
}
}
public class Cindy11 {
public Cindy11() {
}
public static void test(final int x) {
Runnable var10000 = new Cindy11$1(x)
}
}
注意:
这同时解释了为什么匿名内部类引用局部变量的时候,局部变量必须为final。
因为编译期间,创建 Cindy11$1 对象时,将x的值赋值给了 Cindy11
x的属性,所以 val$x 就不在发生变化了。
10.11 综合案例演示
代码清单: 自动装箱、拆箱与遍历循环
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
代码清单10-12 :自动装箱、拆箱与遍历循环编译之后
public static void main(String[] args) {
List list = Arrays.asList( new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}
System.out.println(sum);
}
泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖,代码清单10-12则展示了它们在编译前后发生的变化。
- 泛型就不必说了
- 自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf()与Integer.intValue()方法,
- 而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。
- 再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员的确也就是使用数组来完成类似功能的。
第11章:后端编译与优化
11.1 分层编译
逃逸分析:JVM会判断这个对象是否在这个方法块以外的地方调用。如果有,则认识是逃逸。
可以使用 -XX:-DoEscapeAnalysis 关闭逃逸分析,
public class JIT1 {
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
new Object();
}
long end = System.nanoTime();
System.out.printf("%d\t%d\n",i,(end - start));
}
}
}
经过测试,可以看到在经历过100多次以后,运行时间优化了很多。
原因:
JVM 将执行状态分成了 5 个层次:
- 0 层,解释执行(Interpreter)
- 1 层,使用 C1 即时编译器编译执行(不带 profifiling)
- 2 层,使用 C1 即时编译器编译执行(带基本的 profifiling)
- 3 层,使用 C1 即时编译器编译执行(带完全的 profifiling)
- 4 层,使用 C2 即时编译器编译执行
profifiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的
回边次数】等
即时编译器(JIT)与解释器的区别
相同点:
- 都是将字节码解释为机器码
不同点
- 解释器,下次即使遇到相同的字节码,仍会执行重复的解释,针对所有平台都通用的机器码
- JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译。
根据平台类型,生成平台特定的机器码
11.2 方法内联
public class JIT2 {
// -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining (解锁隐藏参数)打印inlining 信息
// -XX:CompileCommand=dontinline,*JIT2.square 禁止某个方法 inlining
// -XX:+PrintCompilation 打印编译信息
public static void main(String[] args) {
int x = 0;
for (int i = 0; i < 500; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
x = square(9);
}
long end = System.nanoTime();
System.out.printf("%d\t%d\t%d\n",i,x,(end - start));
}
}
private static int square(final int i) {
return i * i;
}
}
经过测试,会看到在一定的运算以后,运算结果的耗时为0。
- 如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置:
System.out.println(9 * 9);
- 还能够进行常量折叠(constant folding)的优化
System.out.println(81);
11.3 字段优化
public void test1() {
for (int i = 0; i < elements.length; i++) {
doSum(elements[i]);
}
}
// @CompilerControl(CompilerControl.Mode.DONT_INLINE)
@CompilerControl(CompilerControl.Mode.INLINE) // 方法内联
static void doSum(int x) {
sum += x;
}
如果开启了方法内联,test1 的优化如下:
@Benchmark
public void test1() {
// elements.length 首次读取会缓存起来 -> int[] local
for (int i = 0; i < elements.length; i++) { // 后续 999 次 求长度 <- local
sum += elements[i]; // 1000 次取下标 i 的元素 <- local
}
}
可以节省 1999 次 Field 读取操作.
11.4 反射优化
第五部分:高效并发
第13章:线程的安全与锁的优化
13.2 线程安全
Java 内存模型:Java Memory Model
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性和原子性的规则以及保障。
13.2.1 Java中的线程安全
1. 原子性
1)引发的问题
代码演示:并发问题
public class ThreadDemo1 {
static int num = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
num++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
num--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num);
/**
* 运行结果:
* -1683
*
* 2022/11/4 16:04
*/
}
}
出现结果得原因如下:
2)解决方案 synchronized
使用 synchronized 关键字:保证原子性和可见性
public class ThreadDemo1 {
static int num = 0;
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
Thread t1 = new Thread(() -> {
/* synchronized (o) {
for (int i = 0; i < 10000; i++) {
num++;
}
}*/
for (int i = 0; i < 10000; i++) {
synchronized (o){
num++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
/* synchronized (o) {
for (int i = 0; i < 10000; i++) {
num--;
}
}*/
for (int i = 0; i < 10000; i++) {
synchronized (o){
num--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num);
/**
* 运行结果:
* 0
*
* 2022/11/4 16:04
*/
}
}
2. 可见性
1)引起的问题
public class ThreadDemo1 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
// System.out.println(1);
}
});
t.start();
Thread.sleep(1000);
run = false;
/**
* 运行结果:
* 线程依旧仍然还会执行
*
* 2022/11/4 16:28
*/
}
}
2)解决方案 volatile
使用 volatile 关键字:修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到
主内存中获取它的值,线程操作 volatile 变量都是直接操作主存
volatile static boolean run = true;
3)volatile 缺点
不能保证原子性。
仅用在一个写线程,多个读线程的情况。
注意:
如果在上面的例子中,加入
System.out.println(1);
,会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了。// 使用了synchronized关键字 public void println(int x) { synchronized (this) { print(x); newLine(); } }
3. 有序性
1)引起的问题
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
① 线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
② 线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
③ 线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
④ 线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行num = 2。
情况④这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现。
特别经典就是 单例模式 的 双重检验锁 (DLC ,Double Check Lock)方式。
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址,此时uniqueInstance
不为空
uniqueInstance
采用 volatile
关键字修饰也是很有必要的。执行顺序有可能变成 1-> 3 -> 2。
指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。
例如,线程 T1 执行了 1 和 3,此时线程 T2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
2)解决方案 volatile
volatile 修饰的变量,可以禁用指令重排。
volatile boolean ready = false;
内存屏障(内存栅栏, Memory Barrier),是CPU的指令,作用如下:
- 保证特定操作的执行顺序
由于编译器和处理器都能够执行指令重排优化,如果在指令见插入一条Memory Barrier,则编译器和处理器他不会将这条Memory Barrier 与其他指令重排,也就是说通过插入内存屏障禁止在内存屏障前后的指令重排序优化。 - 保证某些变量的内存可见性(利用该特性实现了volatile的内存可见性)
内存屏障另外的一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能够读取到这些数据的最新版本。
3)有序性的理解
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序。
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行
时,既可以是
i = ...; // 较为耗时的操作
j = ...;
也可以是
j = ...;
i = ...; // 较为耗时的操作
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。
JDK 5 以上的版本的 volatile 才会真正有效。
4. happens-before
注意
变量都是指成员变量或静态成员变量
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结。
如果抛开了以下的原则,JMM不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。
- 锁住这个对象后,对对象的写操作
static int x;
static Object m = new Object();
public static void main(String[] args) {
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
}
- 使用 volatile 修饰
volatile static int x;
public static void main(String[] args) {
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
}
- 调用其他读线程之前,就对变量进行写操作
static int x;
static Object m = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (m) {
x = 10;
}
}, "t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
}, "t2").start();
}
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或t1.join()等待它结束)。(PS:个人感觉就是等线程变量结束以后,其他线程再去对变量的读
static int x;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
x = 10;
}, "t1");
t1.start();
t1.join();
System.out.println(x);
}
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通
过t2.interrupted 或 t2.isInterrupted)
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
}, "t2");
t2.start();
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
}, "t1").start();
while (!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
- 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
- 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
13.3 线程安全的方法
1. CAS 即 Compare and Swap
它体现的一种乐观锁的思想。比较和交换。
当许多线程去操作一个共享变量的时候,线程会在工作内存里面生成一个变量的副本信息。
当线程对变量的值也就是期望值写回主内存的时候,会首先比较一个主内存的共享变量是否已经被改变:
如果没有改变,则把期望值写回主内存。
如果发生了改变,重新拉去共享变量的值,操作完变量后再比较…
volatile static int 共享变量;
public static void main(String[] args) {
// 需要不断尝试
while (true) {
int 旧值 = 共享变量; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if (compareAndSwap(旧值, 结果)) {
// 成功,退出循环
}
}
}
结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
2. 原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。
private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
// i.getAndIncrement(); // 获取并且自增 i++
i.incrementAndGet(); // 自增并且获取 ++i
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); // 获取并且自减 i--
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
原子类的原理
自旋锁和Unsafe类。
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 内存中的偏移地址
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
}
Unsafe类是CAS的核心类,它在sun.misc
包中。它里面是都是native
方法。而native方法是通过JVM采用C或者C++去实现的。
如:根据valueOffset代表的该变量值在内存中的偏移地址,从而获取数据的。
变量value用volatile修饰,保证了多线程之间的内存可见性。
以getAndIncrement为例,说明其原子操作过程:
public final int getAndIncrement() {
// 当前对象 内存地址偏移量 步长
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//unsafe.getAndAddInt
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 获取当前对象在内存地址偏移量的真实值 var5
var5 = this.getIntVolatile(var1, var2);
}
// 比较对象在内存偏移量里面的值 是否为var5
// 是,var5 加上步长
// 否,再去获取当前对象在内存地址偏移量的值
while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
CAS就是一条CPU的并发原语,CMPXCHG指令,功能就是用来判断内存中某个位置的值是否是预期值,如果是则更改。否则他就不执行更新,但无论是否更新了V的值,都会返回V的旧值。
原语在操作系统之中,是由若干指令去实现的,这个原语的执行过程是连续的,不允许中断,也就不会造成数据不一致的问题。
在CAS中有三个操作数:分别是内存地址(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧的预期值A时,处理器才会用新值B更新V的值,否则他就不执行更新,但无论是否更新了V的值,都会返回V的旧值。
缺点
- 循环时间长开销大
- 只能够保证一个共享变量的原子操作
- 会带来ABA问题
13.3 锁优化
Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的 哈希码 、 分代年龄 ,当加锁时,这些信息就根据情况被替换为 标记位 、 线程锁记录指针 、 重量级锁指针 、 线程ID 等内容。
1. 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
这就好比:
学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,明没有竞争,继续上他的课。
如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
假设有两个方法同步块,利用同一个对象加锁:(可重入锁:ReentrantLock / Synchronized :线程可以进入任何一个他已经的锁的同步代码块)
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word。
线程1 | 对象Mark Word | 线程2 |
访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1锁记录地址 | - |
执行同步块 A | 00(轻量锁)线程 1锁记录地址 | - |
访问同步块 B,把 Mark 复制到线程 1 的锁记录 | 00(轻量锁)线程 1锁记录地址 | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 00(轻量锁)线程 1锁记录地址 | - |
失败(发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | - |
锁重入 | 00(轻量锁)线程 1锁记录地址 | - |
执行同步块 B | 00(轻量锁)线程 1锁记录地址 | - |
同步块 B 执行完毕 | 00(轻量锁)线程 1锁记录地址 | - |
同步块 A 执行完毕 | 00(轻量锁)线程 1锁记录地址 | - |
成功(解锁) | 01(无锁) | - |
- | 01(无锁) | 访问同步块 A,把 Mark 复制到线程 2 的锁记录 |
- | 01(无锁) | CAS 修改 Mark 为线程 2 锁记录地址 |
- | 00(轻量锁)线程 2锁记录地址 | 成功(加锁) |
- | … | … |
2. 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
}
}
线程1 | 对象Mark Word | 线程2 |
访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1锁记录地址 | 访问同步块,把 Mark 复制到线程 2 |
执行同步块 | 00(轻量锁)线程 1锁记录地址 | CAS 修改 Mark 为线程 2 锁记录地址 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现别人已经占了锁) |
执行同步块 | 00(轻量锁)线程 1锁记录地址 | CAS 修改 Mark 为重量锁 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞中 |
执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
失败(解锁):当初加锁的方式为轻量级锁,但是现在变为了重量级锁。 | 10(重量锁)重量锁指针 | 阻塞中 |
改为释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
- | 10(重量锁) | 竞争重量锁 |
- | 10(重量锁) | 成功(加锁) |
- | … | … |
3. 重量级锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
- Java 7 之后不能控制是否开启自旋功能
自旋重试成功的情况
线程 1(cpu 1上) | 对象 Mark | 线程 2(cpu 2上) |
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
成功(解锁) | 01(无锁) | 自旋重试 |
- | 10(重量锁)重量锁指针 | 成功(加锁) |
- | 10(重量锁)重量锁指针 | 执行同步块 |
- | … | … |
自旋重试失败的情况
线程 1(cpu 1上) | 对象 Mark | 线程 2(cpu 2上) |
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 01(无锁) | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
… | … | … |
4. 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS。
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的 hashCode 也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,
- 重偏向会重置对象的 Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
5. 其他优化
1. 减少上锁时间
同步代码块中尽量短
2. 减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
- ConcurrentHashMap
- LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
- LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
3. 锁粗化
多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,
没必要重入多次)
new StringBuffer().append("a").append("b").append("c");
4. 锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候
就会被即时编译器忽略掉所有同步操作。
5. 读写分离
CopyOnWriteArrayList
ConyOnWriteSet