JVM 介绍
JVM:Java 程序的运行环境(Java二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界检查
- 多态
比较:
JVM、JRE、JDK
JDK(JRE + 编译工具(javac、jps、jstack、jmap、jconsle等)) > JRE(JVM + 基础类库(lang包下的String类等)) > JVM(运行字节码文件)
常见的 JVM
学习路线
JVM 组成
- 类加载器(类加载的几个阶段)
- JVM 内存结构
- 执行引擎
内存结构
1. 程序计数器
概念
PC(程序计数器)作用:记住下一条 JVM 指令的执行地址
通过寄存器来实现的,通过寄存器来读取存储地址
- 特点
- 线程私有的
- 不会存在内存溢出
2. 虚拟机栈
概念
(栈内存是线程私有的)
-
虚拟机栈:每个线程运行时需要的内存空间(由多个栈帧组成)
-
栈帧:每个方法运行时需要的内存(包含:参数,局部变量,返回地址),一个栈帧对应一次方法调用
-
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
问题辨析
- 垃圾回收是否涉及栈内存
答:不涉及,栈帧每次对应方法调用完后,都会弹出栈,不需要垃圾回收管理 - 栈内存分配越大越好吗?
答:不是,栈内存划分的大,会让线程数变小
栈内存可以通过参数去指定:-Xss size(设置的是每个线程可以拥有的最大栈内存大小,而非总的栈空间)
- 方法内的局部变量是否线程安全?
多个线程同时执行此方法,但是变量是局部变量。是线程私有的,每次调用会产生新的栈帧,所以不会出现线程安全问题,如果 将 x 变为 static int x = 0,则会出现线程问题。
- 如果方法内局部变量没有逃离方法的作用访问,它就是现场安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
栈内存溢出
(StackOverflowError)
- 栈帧数量过多导致栈内存溢出
- 递归调用
- 两个类之间循环引用/依赖(Spring通过三级缓存解决)
- 栈帧过大导致栈内存溢出
三方库转json,自定义的两个类循环依赖,转json出错
@JsonIgnore 处理,中断其中一个转json
线程运行诊断
案例1:CPU占用过多
目标:找到哪个线程对 cpu 占用过高
注意:jstack 后面的进程id 是10进制的,但是查出来的线程 id 是16 进制,需要做转换
案例2:程序运行了很长时间没有结果(死锁)
通过 “jstack 进程id” 来定位分析
在 jstack 命令输出的 最后
3. 本地方法栈
概念
Java 虚拟机调用本地方法(native method)时,需要给这些本地方法提供的内存空间
本地方法:用 C、C++ 实现的方法,通过 Java 间接调用到
- clone()、hashCode()、wait()、notify()、notifyAll()
4. 堆
概念
- 通过 new 关键字,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
堆内存溢出
- 堆内存耗尽:OutOfMemoryError
- 控制堆空间的参数:
-Xmx1024m
堆内存诊断
- jps工具
- 查看当前系统中有哪些 java 进程(可以查看对应的 进程Id)
- jmap 工具
jmap -heap 进程id
(只能查看某一时刻)
- 查看堆内存占有情况
- jconsole 工具
- 图形界面的,多功能的监测工具,可以连续监测(连续监测)
可以查看 堆内存使用、线程、类、CPU 占用率
jconsole 也可以和 jstack 一样监测死锁
- jvisualvm
案例:垃圾回收后,内存占用仍然很高
和 jconsole 功能类似,但是更好用
- 堆 Dump (堆转储)
抓取当前堆的快照,进一步对里面的内容进行分析(jmap、jconsole不支持)
点击 查找 可以查看堆中的 最大对象
点进去最大对象后的详细信息:
5. 方法区
概念
所有 Java 虚拟机线程共享的区域,存储和类相关的信息(方法/构造器代码、运行时常量池等)(逻辑是堆的一部分)
方法区是规范,永久代、元空间都是它的一种实现
方法区也会 OOM
永久代
JDK 1.6:永久代作为方法区的实现
包含的内容
- 存储类的信息
- 类加载器
- 运行时常量池(字符串表)
元空间
JDK1.8:元空间作为方法区的实现(不是由JVM管理,被移动到本地内存中)
- 类信息
- 类加载器
- 运行时常量池
注意:串池(StringTables)不在放在方法区的实现(元空间),而放到堆中了
方法区内存溢出
模拟元空间溢出
模拟永久代溢出
Spring(Aop)、Mybatis(Mapper实现类)
都使用字节码技术 cglib 生成代理类
运行期间动态生成类字节码,完成动态的类的加载
运行时常量池
类生成二进制字节码信息,(方法区)包含
- 类基本信息
- 常量池
- 类方法定义
- 虚拟机指令(指令内容的详细信息存储在常量池中)
常量池
反编译命令:javap -v 类名.class
综上,
常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
运行时常量池:上面提到常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入 运行时常量池 ,并把里面的 符号地址 变为 真实地址
即:常量池中保存的常量池信息(最初是在字节码文件里)当class文件对应类被加载时会被放入到运行时常量池
StringTable(串池)
常量池中的信息,都会被加载都运行时常量池中,这时 a b ab 都是常量池中的符号,还没有变为 Java 字符串对象(得等到具体执行到相应的代码时,才会变为字符串对象)
- 提前准备好一个空间:StringTable[](串池) 初始为空(数据结构为 哈希表)(大小固定,不能扩容)
- ldc #2 会把 a 符号 变为 “a” 字符串对象
- 之后,会把 字符串对象作为 key,到 StringTable 中找,有没有一个取值相同的 key
- 如果没有的话,会把 字符串对象 放入 StringTable(串池)中
注意:
- 字符串对象是懒创建的,遇不到时,不会把它创建出来
- 创建出来时会放入串池(如果串池中就有,则会用串池对象的)
反编译结果显示,这里的 s1 + s2 会创建新的 String 对象
所以 s3 == s4 结果为 false
串池中有了,会沿用串池中已有的对象
(javac 编译器优化)
结果为 s3 == s5 结果为 ture
StringTable特性:
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是 StringBuilder (1.8)
- 字符串常量拼接的原理是编译期优化
- 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
jdk1.8 intern() 方法:
- 如果当前对象对应的字符串不在串池中,则会将当前对象放入串池,并返回串池中的对象
- 如果当前对象对应的字符串在串池中有了,则直接返回串池中的字符串对象
jdk1.6 中
如果当前字符串对象不在串池中存在时,会复制一份(重新创建一份字符串对象)放入串池(此时堆中的字符串和串池中的对象不是同一份)
例子:
eth1:
String s = new String("a") + new String("b"); // StringBuilder -> new String("ab") 在堆中
String s2 = s.intern(); // (尝试将字符串对象添加到串池中,如果有则不添加),最后返回串池中的字符串对象
System.out.println(s2 == "ab"); // true
System.out.println(s == "ab"); // true
eth2:
String x = "ab"; // 字符串字面量,在串池中
String s = new String("a") + new String("b"); // StringBuilder -> new String("ab") 在堆中
String s2 = s.intern(); // (尝试将字符串对象添加到串池中,如果有则不添加),最后返回串池中的字符串对象
System.out.println(s2 == "ab"); // true (s2指向串池中的字符串对象)
System.out.println(s == "ab"); // false (s指向堆中的字符串对象)
最前面的测试题:
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern(); // 返回串池中的字符串"ab"对象
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 如果掉换了【最后两行代码位置】,则输出为true
System.out.println(x1 == x2); // false
StringTable的位置
jdk1.6 中,StringTable 是常量池的一部分,都在永久代中(永久代只有 fullGc 的时候才会回收【老年代空间不足】,导致StringTable回收效率不高,占用大量内存,导致永久代内存不足)
jdk1.8中,StringTable 被放在堆中了(堆里的StringTable,只要minorGC 就会触发回收)
StringTable 垃圾回收机制:
StringTable 也会受到垃圾回收的管理,当内存空间不足时,StringTable 中那些 没有被引用 的 字符串常量 也会被垃圾回收
StringTable 性能调优:
StringTable 是基于哈希表实现(过小了容易哈希冲突【效率慢】,过大又很占内存)
调优:其实就是调整 HashTable 哈希表的大小
• 调整-XX:StringTableSize=桶个数
• 考虑将字符串对象是否入池
这个案例中,字符串入池可以节省内存(自动去重),此时 list 中存放的是串池对象的引用,堆中创建的对象会被 GC 回收
6. 直接内存
基本使用
属于系统内存(操作系统内存)
Direct Memory
• 常见于 NIO 操作时(IO操作的一种),用于数据缓冲区(Todo:看完 netty 来看)
• 分配回收成本较高,但读写性能高
• 不受JVM 内存回收管理
- 为什么使用了直接内存,读写效率会非常高?
Java 本身不具备磁盘读写的能力,如果要调用磁盘读写,必须调用操作系统提供的函数(调用本地方法)(cpu 从用户态切换到内核态)
内存:cpu 就可以读取磁盘的内容了(Java读取的话:磁盘文件 -> 系统缓存区 -> Java 缓存区【Java才能读取】)
使用了 byteBuffer 之后:会在操作系统中分配一块 直接内存 (Java 可以直接读取)
内存溢出
- 不受 JVM 内存回收管理
直接内存溢出案例:
ByteBuffer.allocateDirect 方法会在 直接内存中分配一块 大小为 参数的空间
释放原理
- 直接内存的释放实际 是通过 unsafe(freeMemory方法) 管理的(而不是 垃圾回收【垃圾回收只能释放Java内存】)
- 直接垃圾回收 回收掉ByteBuffer 会触发 Cleaner 通过回调 间接 触发 freeMemory方法
- ByteBuffer.allocateDirect 方法 就是调用了 unsafe.allocateMemory 方法完成对直接内存的分配
- Cleaner 后面的 create 方法的第二个参数对象(回调任务) 类实现了 Runnable 接口的 run 方法,run 方法里调用了 freeMemory 对象
- Cleaner(虚引用类型),当它关联的对象(create 方法的第一个参数)被回收时,会触发 Cleaner 的 clean 方法
• 使用了 Unsate 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
• ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHiandler 线程通过 Cleaner 的clean 方法调用 freeMemory 来释放直接内存
禁用显式回收时,对直接内存释放的影响:
加 -XX:+DisableExplicitGC 禁用显式的垃圾回收,System.gc 失效,但 unsafe.freeMemory 也可能跟着失效
-
因为 unsafe.freeMemory 的调用是 ByteBuffer 对象没有任何指向,然后被 GC 回收后,会通过 Cleaner 的 clean 方法回调 unsafe.freeMemory 方法回收直接内存的
-
如下显式的 System.gc 调用后,直接内存还是没有被回收,只有等系统下次自动 GC 时,才会回收掉 ByteBuffer 对象,从而间接触发 unsafe.freeMemory 回收直接内存
-
此时,虽然 System.gc 显式垃圾回收失效了,但是可以直接通过手动调用 unsafe.freeMemory 方法管理直接内存
垃圾回收
如何判断对象可以回收
- 引用计数法
一个对象被变量引用一次,计数器就 +1,引用几次,计数器就为几,如果某个变量不再引用时,计数器 -1,当引用计数器为 0 时,则会被垃圾回收
会有循环引用问题
- 可达性分析算法(Java 虚拟机是通过可达性分析算法来回收对象)
要确定一系列根对象
根对象:肯定不能被当成垃圾回收的对象
在垃圾回收之前,会对堆中的所有对象进行一遍扫描,然后查看当前对象是否被根对象直接或间接引用,如果是,则当前对象不能被回收,反之,如果当前对象没有被引用,则会被当作垃圾回收
• Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
• 扫描堆中的对象,看是否能够沿着GC Root对象起点的引用链找到该对象,找不到,表示可以回收
•哪些对象可以作为 GC Root?
- System Class
系统类,由启动类加载器加载的类,核心的类(作为GC ROOT 对象,包括 Object、String、HashMap 一些核心的类对象) - Native Stack
操作系统方法执行时,所引用的一些 Java 对象作为根对象 - Thread
活动线程,线程还在运行,其中线程需要的一些对象肯定不能被回收了(栈帧内所使用的一些局部变量所引用的 对象 可以作为根对象) - Busy Monitor
同步锁对象,正在加锁的对象,也是可以作为根对象
GC ROOT 指的是堆中的对象,而不是其引用
注意:根对象当没有被引用时,也可以被回收
四种引用
- 强、软、弱、虚、终结器 — 引用
实线代表强引用
强引用:一般创建且通过赋值运算符赋值给一个变量,变量就强引用了这个对象(只要沿着GCRoot 引用链能找到这个对象,就不能被回收)
软引用:垃圾回收后,且内存仍然不足时,就会被回收
弱引用:只要发生垃圾回收,就会被回收掉
注意,软引用和弱引用本身也是对象,如果指向的对象被回收了,则软引用、弱引用会被放入引用队列,这样就可以方便回收软/弱对象本身了
虚引用:在虚引用 引用的对象被垃圾回收时,虚引用对象自己就会放入引用队列,从而间接由一个线程来调用虚引用对象的方法,然后调用 unsafe.freeMemory 去释放直接内存
类似回调要回收直接内存
终结器引用:当 当前对象重写了 Object 类的终结方法(finallize方法),并且没有强引用 引用时,它就可以被当成垃圾回收
当没有强引用引用时,虚拟机会创建上面对象的 终结器引用,当这个对象要被垃圾回收时,终结引用也会被加入 引用队列,再由其他线程先调用 finallize方法,再去回收(优先级低,不容易被回收)
虚/终结器引用必须配合引用队列使用(创建时,会直接关联引用队列,强制!软/弱 非强制)
软引用举例:
配合引用队列,获取软引用对象,并删除(队列软引用构造器的作为第二个参数)
弱引用举例:
回收算法
-
标记—清除
先标记存活对象,再把未标记的垃圾对象(无gc root 引用)清除(快、但是有内存碎片,不连续)
-
标记—整理
先标记存活对象,将可用对象向前移动,让内存更为紧凑(避免内存碎片)(效率较低)
-
复制算法
将内存分为大小相等的两个区域,from 和 to,to这个区域是空闲的,首先在from区域标记不能被回收的存活对象,将存活的对象,复制到 to 的区域中(过程中会整理),复制完成后,清空 from 区域的内存,接着交换 from 和 to 定义
上面3种回收算法,在 JVM 中会分情况使用不同算法
分代回收
- 在JVM堆内存中调整新生代大小的方式有两种主要途径:调整新生代/老年代的比例或直接指定新生代的大小。
把堆内存分为 新生代、老年代
新生代分为:伊甸园、幸存区from、幸存区to
对不同区域使用不同的回收算法(新生代回收频繁、老年代回收不频繁)
分代回收机制:
- 创建的对象,先分配到伊甸园中
- 当伊甸园满了之后,就会触发一次垃圾回收(新生代叫Minor GC:可达性分析),回收后,存活对象复制到 to 区域中,并且幸存对象,寿命+1,伊甸园清空
- from 区域、to 区域的名字定义交换
- 然后新对象又在伊甸园分配,当伊甸园又满时,再触发第二次垃圾回收
- 第二次垃圾回收时,不止分析伊甸园可回收对象,还要分析幸存者区可回收对象,回收垃圾对象
- 将伊甸园存活对象放入 to 区域(寿命+1)、且将 from 区域存活对象放入 to 区域(寿命+1)
- 当幸存区对象,寿命超过某个阈值15 时,就会将其移动到 老年代 中
注意:minor gc 会引发 stop the world(在发生垃圾回收的时候,必须暂停其他的用户线程),由垃圾回收线程完成垃圾回收的动作,当 回收动作完后,用户线程才可以继续执行(防止回收时用户线程也执行,对象地址引用混乱)
当新生代和老年代空间都满了时(不能存放新对象),触发 full GC(minor 后空间仍不足才触发)
老年代采用 标记+清除/整理 算法
如果 full GC 后,老年代空间仍不足,则会触发 OOM
总结:
GC
相关 VM 参数
GC 是 minor gc、full gc 就显示的是 full gc
当新生代空间不足时,一些对象寿命没有到 15,也会被移动老年代
大对象直接晋升到老年代
一个子线程 OOM,不会使得整个 Java 进程结束,不影响主线程继续执行
三种垃圾回收器
- 串行(SerialGC)
- 单线程
- 适用场景:堆内存较小,适合个人电脑(cpu个数少)
- 吞吐量优先(ParallelGC)
- 多线程
- 场景:堆内存较大,多核 cpu
- 目标:让 单位时间内,STW 的时间最短
- 吞吐量=(运行用户代码时间)/(运行用户代码时间+运行垃圾收集时间)
- 响应时间优先(CMS)
- 多线程
- 场景:堆内存较大,多核 cpu
- 注重尽可能让垃圾回收时 单次 STW时间短
串行
老年代:标记整理
吞吐量优先
ration 默认值 是 99,代表 1 / (1 + 99),代表 100 分钟内,运行 1 分钟内的垃圾回收时间(达不到时,会调大堆内存时间,减少回收次数)
200 ms 指每次回收时间
多线程,多个垃圾回收线程并行执行,但用户线程 STW 了
jdk 1.8 默认开启
老年代:标记整理
响应时间优先
CMS 垃圾回收器(某些情况会退回串行)
concurrent:并发(其中一些阶段,可以和用户线程并发执行)(只有部分时间才 STW)
可能影响应用程序的吞吐量(因为有一部分用cpu用来垃圾回收了,用户线程能使用的cpu就少了)
年轻代回收:与Parallel GC类似,采用并行复制算法。
老年代回收:采用 并发标记-清除 算法,大部分垃圾回收工作与应用线程并发执行,减少停顿时间(某些情况会退回串行)。
- 老年代发生内存不足,cpu 到安全点暂停下来,CMS 进行 初始标记(STW)(只标记根对象GC root,比较快)、然后用户线程恢复运行,垃圾回收线程可以进行 并发标记(应用程序响应时间短),并发标记完成后,进行 重新标记,需要 STW (因为并发标记阶段,用户线程也在运行,可能对回收过程有影响,产生新垃圾对象,改变对象的引用等,会对并发标记阶段进行干扰),重新标记完成后,用户线程 恢复运行,此时再进行一次 并发清理。
- 只有 初始标记 和 重新标记 阶段 才会 STW
老年代:标记清除
G1
定义:Garbage First
• 2004论文发布
• 2009 JDK 6u14体验
• 2012 JDK 704 官方支持
• 2017 JDK9 默认
适用场景
• 同时注重吞吐量(Throughput) 和低延迟(Low Latency),默认的暂停目标是 200 ms
• 超大堆内存,会将堆划分为多个大小相等的 Region
• 整体上是标记+整理算法,两个区域之间是复制算法
相关 JVM 参数
-XX:+UseG1GC
-XX:G1HeapRegionSize=size
-XX:MaxGCPauseMillis=time
代替 CMS 回收器
- G1 垃圾回收阶段
循环的过程
Mixed GC:收集整个新生代和部分老年代的垃圾收集,目前只有G1有这种行为
- Young Collection(新生代回收)
每个区域都可以独立作为伊甸园,幸存区,老年代
每一个region代表一种区域 伊甸园 幸存区0 幸存区1 老年代
E:伊甸园(会设置总的大小)
当 伊甸园 满了之后,会触发 STW(新生代垃圾回收),会将幸存对象以拷贝的形式拷贝的幸存者区。
当幸存区满了,也会触发回收(和之前一样),当到一定年龄,会被放入老年区
- Young Collection + CM(新生代回收+CM并发标记)
(初始标记 和 并发标记)
初始标记:找到那些根对象进行标记(会发生STW)(Young GC 时进行)
并发标记:从根对象出发,顺着引用链,标记其他的对象(与用户线程共同执行,不会STW)(老年代终于堆内存空间 45%+的时候触发)
• 在 YoungGC 时会进行 GC Root 的初始标记
• 老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),由下面的JVM 参数决定
-XX:InitiatingHeapOccupancyPercent=percent (默认45%)
当老年代占比,占总堆内存的 45% 的时候,就进行 并发标记 了
- Mixed Collection
会对 E、S、O 进行 全面 垃圾回收
在 Mixed Collection 的 最终标记 和 拷贝存活 阶段都会 STW
最终标记(并发标记可能会漏掉一些对象,因为并发标记时,其他线程也在工作,可能会产生新垃圾对象),所以需要在 混合回收 的阶段,STW,进行 最终标记
伊甸区和幸存者区都会回收,未回收的对象拷贝到其他 S 区、够年龄的复制到老年代区域,老年代一些被 并发标记 阶段,回收一部分没用的对象,也会复制到 其他 O 区域,
G1 会根据 STW(最大暂停时间) 时间,有选择的进行回收O(有时候堆内存空间太大了,老年代回收时间比较长,可能达不到最大暂停时间目标,G1才会选择性的挑出回收价值最多的(能回收的内存多的) 区域进行回收。(否则则会全回收)
G1垃圾回收器的整个回收过程分为以下几个阶段:
1) 初始标记阶段:STW,标记 GC Roots 直接引用的对象。
2) 并发标记阶段:与用户线程并发执行,标记所有可达对象。
3) 最终标记阶段:STW,修正并发标记阶段可能出现的标记错误。
4) 清理阶段:对回收区域进行评估和清理
- Full GC
- SerialGC
- 新生代内存不足发生的垃圾收集 -minor gc
- 老年代内存不足发生的垃圾收集 -full gc
- ParallelGC
- 新生代内存不足发生的垃圾收集 -minor gc
- 老年代内存不足发生的垃圾收集 -full gc
- CMS
- 新生代内存不足发生的垃圾收集. -minor gc
- 老年代内存不足(当垃圾回收速度小于产生速度,则会触发 串行回收【full gc】,否则不会 full gc)
- G1
- 新生代内存不足发生的垃圾收集 -minor gc
- 老年代内存不足(当垃圾回收速度小于产生速度,则会触发 多线程回收【full gc】,否则不会 full gc)
跨代引用(Young GC):每个 region 都有一个 remember set,记录其他区域对当前区域对象的引用(老年代对象对新生代对象的引用),垃圾回收时,可以快速扫描哪些外部引用,引用了当前对象,快速的判断当前对象是否可回收(不用扫描整个 老年代区)
- Young Collection 跨代引用(新生代垃圾回收—跨代引用)
在Java虚拟机(JVM)的垃圾回收机制中,新生代垃圾回收(Minor GC)是一个频繁发生的操作,主要用于清理新生代中的对象。然而,新生代中的对象可能会被老年代中的对象引用,这种跨代引用需要扫描整个老年代区域。为了解决这个问题,JVM引入了 Remembered Set(记忆集) 的概念。
找新生代对象的根对象(有一部分来自 老年代)(直接遍历的话,需要花费很长时间)
通过 card table 来标记,当有个 card 引用 新生代中 的对象,则会标记 为 脏 card
Eden 区会通过 remembered set 标记当前对象被那些 脏 card 引用,垃圾回收时,只需要通过 remembered set 找到脏卡,再通过 脏卡 遍历 GC ROOT,再进行垃圾标记,然后进行复制—清除,加速新生代垃圾回收
- Remark(重标记)
并发标记 时的处理状态
黑色:已经处理完(存活的)
灰色:还在处理中(最终变成黑色存活)
白色:没有被处理(如果被强引用,则会变成 黑色 存活下来,否则白色,清除)
初始状态时:
开始并发标记(和用户线程同时执行)
由于 B 是被强引用的,标记为黑色,假如有用户线程同时对 C 的引用进行了修改
假如先断开了引用,但是此时并发标记,标记为会被回收(白色)
然后处理 完 C 后,又有用户线程改变了 C 的引用,但是此时 C 已经被标记为需要被清理,并且 A 已经标记为 处理过了,不会再 顺着 A 去标记 C 对象,此时,C 对象还是会被清理(这是有问题的)
所以,重标记阶段引入了一个概念:内存屏障
- 并发标记阶段,当对象的引用发生改变时,JVM 就给他加入一个写屏障(对象引用发生改变,写屏障的指令就会执行,将对象加入到 队列当中,并将对象标记为 灰色 处理中)
- 当 并发标记 执行完成后,重标记 阶段会遍历 队列中 的元素,进行重新处理,判断是否可以被回收,就避免了上面的问题!
- JDK 8u20 字符串 去重
- 优点:节省大量内存
- 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
-XX:+UseStringDeduplication
String s1 = new String("hello"); // char[]{'h', 'e', '1', 'I', 'o'}
String s2 = new String("hello"); // char[]{'h', 'e', '1', '1', 'o'}
-
将所有新分配的字符串放入一个队列
-
当新生代回收时,G1 并发检查是否有字符串重复
-
如果它们值一样,让它们引用同一个 char[]
-
注意,与 string.intern() 不一样
- String.intern() 关注的是字符串对象
- 而字符串去重关注的是 char[](字符串对象不一样,但是指向同一个 char 数组)
- 在JVM内部,使用了不同的字符串表
- JDK 8u40 并发标记类卸载
所有对象都经过并发标记后,就能知道哪些类不再被使用(类对应的对象实例都被回收),当类对应的对象实例都被回收,并且这个类所在的类加载器的所有类都不再使用,则卸载这个类加载器所加载的所有类(只能是自定义类加载器)
-XX:+ClassUnloadingwithConcurrentMark 默认启用
- JDK 8u60 回收巨型对象
- 一个对象大于region 的一半时,称之为巨型对象
- G1 不会对巨型对象进行拷贝
- 回收时被优先考虑
- G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0的巨型对象就可以在新生代垃圾回收时处理掉(老年代区域对巨型对象引用为 0 时,就可以回收)
- JDK 9 并发标记起始时间的调整
- 并发标记必须在堆空间占满前完成,否则退化为 FullGC
- JDK 9之前需要使用 -XX:InitiatingHeapOccupancyPercent
- JDK 9可以动态调整
- -XX:InitiatingHeapOccupancyPercent 用来设置初始值
- 进行数据采样并动态调整
- 总会添加一个安全的空档空间
- JDK 9 更高效的回收
- 250+增强
- 180+bug修复
- https://docs.oracle.com/en/java/javase/12/gctuning
GC 调优
查看虚拟机运行参数
- java -XX:+PrintFlagsFinal -version | findstr “GC”
- java -XX:+PrintFlagsFinal -version | grep “GC”
预备知识
- 掌握GC 相关的 VM参数,会基本的空间调整
- 掌握相关工具
- 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则
- 调优领域
- 内存
- 锁竞争
- cpu 占用
- io
- 确定目标
- 【低延迟】还是【高吞吐量】,选择合适的回收器
- CMS,G1,ZGC(低延迟)
- ParallelGC(高吞吐量)
- Zing
- 最快的GC是不发生GC
- 查看 FullGC前后的内存占用,考虑下面几个问题
- 数据是不是太多?
- resultSet = statement.executeQuery(“select * from 大表”)
- 数据表示是否太臃肿?
- 对象图
- 对象大小
- 数据是不是太多?
- 是否存在内存泄漏?
- static Map map = (一直加强引用对象,不删除)
- 考虑如下:
- 软
- 弱
- 第三方缓存实现
- 新生代调优
- 新生代的特点
- 所有的 new燥作的内存分配非常廉价(伊甸园创建对象非常快)
- TLAB thread-local allocation buffer(每个线程都会在伊甸园中分配一块私有区域,用自己私有的内存进行对象内存的分配)
- 死亡对象的回收代价是零
- 大部分对象用过即死(大多数对象用过就会被回收)
- Minor GC 的时间远远低于 Full GC
- 所有的 new燥作的内存分配非常廉价(伊甸园创建对象非常快)
新生代能容纳所有【并发量 * 一次(请求-响座)产生的对象内存】的数据
幸存区大到能保留【当前活跃对象+需要晋升对象】(防止有的存活时间短的对象,被提前晋升到老年代,进而推迟到 full gc 时才能回收)
晋升阈值配置得当,让长时间存活对象尽快晋升
-XX :MaxTenuringThreshold=threshold(最大晋升阈值)
-XX: +PrintTenuringDistribution(打印不同年龄的对象所占用空间)
Desired survivor size 48286924 bytes, new threshold 10 (max 10)
- age 1: 28992024 bytes, 28992024 total
- age 2: 1366864 bytes, 30358888 total
- age 3: 1425912 bytes, 31784800 total
- 老年代调优
以 CMS为例
- CMS 的老年代内存越大越好
- 先尝试不做调优,如果没有 Full GC那么已经…,否则先尝试调优新生代
- 观察发生 Full GC 时老年代内存占用,将老年代内存预设调大1/4~1/3
- -XX: CMSInitiatingOccupancyFraction=percent(值越小,full gc 发生的时间越早)
- 案例
- 案例1 Full GC 和 Minor GC频繁
增大了新生代的内存导致minorgc更少触发,并且survivor区增大,就不会让本不是生命周期那么长的对象进入老年区,从而给老年区节省空间,进一步就减少了老年区出发fullGC
-
案例2 请求高峰期发生 Full GC,单次暂停时间特别长(CMS)
查看 GC 日志,发现重新标记 时间比较长(初始标记/并发标记一般比较快)
CMS 重新标记时,会扫描整个的堆内存(老年代+新生代),请求高峰期时,新生代对象个数比较多,所以扫描标记的对象就比较多(根据对象找他的引用)
通过下面的参数,在重新标记阶段发生之前,先对新生代的垃圾进行一次清理,重新标记阶段需要标记的对象就少很多
就解决了 重新标记阶段 暂停时间长的问题 -
案例3 老年代充裕情况下,发生 Full GC(CMS jdk 1.7)
(CMS 有可能因为空间不足,并发失败,或者空间碎片比较多,都会产生 full gc)
但是排查,老年代空间充裕
1.7 永久代实现方法区
永久代空间不足,也会触发整个堆的 full gc
类加载
类文件结构
编译 java 文件为 二进制字节码 .class 文件
- 魔数:文件的特定类型(标识文件类型)
- 版本
3. 常量池
记录了 Java 类中的各种信息:类的信息、父类信息、方法信息、成员变量信息、方法属性等…
…
- 访问标识与继承信息
-
field 信息
-
method 信息
-
附加属性
字节码指令
21 b7 这些都叫字节码指令
方法体内有一些字节,就是由 JVM 来执行的方法的代码
构造方法:5个字节(字节码指令)(JVM 可以识别平台无关的字节码指令,最终解释为机器码执行)
main 方法:9个字节
2a:(aload_0)把局部变量表中的变量加载到操作数栈中,JVM 执行的时候,大多数时间会在操作数栈上读取数据(aload_0:把局部变量表 0 槽位上的变量加载到操作数栈中)
b7:方法调用
00 01:方法引用
b1:表示返回
javap 工具
javap 反编译 class 二进制字节码文件
-v 参数,输出类文件的详细信息
- 文件本身的属性信息
- 类的全包类名(jdk 8、public)
- 常量池(引用 #6.#21,后面的注释就是对应拼出来的)
- 方法信息:构造方法(参数信息、访问修饰符、代码)、main 方法
图解运行流程
图解方法执行流程:
Java 代码被执行时,都发生了什么?
- 类加载器加载 main 方法所在的类(类加载就是将class文件的数据读到内存中)
- 其中常量池 (class文件中的常量池数据)中的数据会被放入 运行时常量池(方法区的一部分) 中
- Short.MAX_VALUE 范围内的和字节码指令存储在一起,否则则存储在常量池中
3)常量池载入运行时常量池
4)方法字节码 载入方法区
5)main 线程开始执行,分配栈帧内存
- 为 main 线程的main方法分配栈帧内存(绿色代表局部变量表,蓝色是操作数栈)
stack=2:最大操作数栈深度为 2(存储数据和字节码指令)
local=4:局部变量表是 4(存储局部变量)
6)执行引擎开始执行字节码(方法区的代码)
给 a 变量赋值
槽位:args、a、b、c,局部变量表的这些槽位分别用来存储对应变量的值
给 b 变量赋值
因为 32768 超过了 Short.MAX_VALUE,所以存储在了 运行时常量池中,ldc #3,会从常量池中加载这个数字到操作数栈中
a + b 的操作在操作数栈中才能完成
在常量池中找到成员变量的引用(堆中),将引用放入操纵数栈中
练习—分析 i++
练习
- iload_x 将 0 放入操作数栈
- iinc_x 在局部变量表中,将 x 自增,变为 1
- istore_x 将操作数栈中的 0 放入 局部变量表 x 对应的槽位,覆盖 原来的 1,所以结果一直是 0
所以 i = 30
构造代码块比构造函数先执行
方法调用
- invokespecial/invokestatic 在编译阶段是可以确定调哪个类的哪个方法(静态绑定)(构造方法、私有方法、静态方法)
- invokevirtual 在编译期间不能调用对象的哪个方法,需要运行时确定(动态绑定)(public 可能出现方法重写)
多态?
上面通过对象调用静态方法时,会产生两条没用的虚拟机指令:
20.21:aload_1、pop,入栈后直接出栈(无用指令)
多态原理
invokevirtual(多态调用)
HSDB 工具
通过 HSDB 连接刚才的程序
通过sqql 语句查找 JVM 中的对象(内存地址)
点击对应的对象地址,可以查找到对象的信息(对象在内存中实际的结构)(对应c++的struct)
对象的信息:
- 对象头(一共16字节)
- mark(对象hash码、锁标记)(8字节)
- metadata(类型指针、找到对象的class类)(8字节)
- 对象成员变量
通过 mem 命令,查看 mark、metadata 对应的 指针地址
对象在内存中的完整表示(方法区,包含类的所有信息)
类中的多态的方法是存在于 vtable 虚方法表中(静态、final、私有、构造都不会在这个表中)
从堆中找到对象,通过对象找到方法区对应的类文件信息,然后通过类文件信息中的方法表,找到实际方法的地址
一句话总结:invokevirtual指令调用的对象vtable中的方法
虚方法表是在类的加载过程中的链接阶段就会生成虚方法表(vtable)
小结
当执行 invokevirtual指令时,
- 先通过栈帧中的对象引用找到对象
- 分析对象头,找到对象的实际 Class
- Class 结构中有 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
- 查表得到方法的具体地址
- 执行方法的字节码
异常
异常表:
监测区间 [2, 5) 行的代码,如果出现 type 对应异常,则跳到 target 对应的行
athrow 是用来抛出 catch 块没有捕捉到的异常
finally 分支相当于 copy 了 三份:
- try 分支后
- catch 分支后
- 没有被 catch 捕捉的异常后
返回结果为 20
建议不要在finally里return,会吞掉异常
正常打印 20
结果为 10
因为 try 块 中的 值做了 暂存,finally 块执行完后,会将暂存的值加载到操作数栈顶,进行返回。
finally 中不存在 return 的话,不会吞掉异常
synchronized
如何保证 synchronized 加锁,解锁成对存在
- 9: 复制两份,是因为一个给 monitorenter用,一份给 monitorexit 用
- 10: 将一份引用放入 2 号槽位了
- monitorenter 对栈顶剩下的一个 lock 引用进行加锁操作
- 根据异常表,如果 [12, 22) 代码没出现问题,会执行 21 的代码进行释放锁
- 如果出现异常,会跳到 25 执行,也会执行 27 的代码进行释放锁操作
编译期处理
所谓的 语法糖,其实就是指java编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是java 编译器给我们的一个额外福利(给糖吃嘛)注意,以下代码的分析,借助了javap工具,idea的反编译功能,idea 插件jclasslib 等工具。另外,编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了 几乎等价的java 源码方式,并不是编译器还会转换出中间的java 源码,切记。
语法糖
默认构造器
编译器会为类生成一个无参的默认构造器
自动拆装箱
valueOf 中 -128~127 会进行对象复用
泛型集合取值
泛型擦出
- 27:checkcast 取出元素的时候,进行强制类型转换
- 局部变量类型表,保存了 类型 信息
- 但是通过反射不能获取这个信息
- 但是这种返回值/参数中的 泛型信息,是可以通过反射获取到的
可变参数
foreach 循环
switch 字符串
switch 枚举
会生成一个静态内部类,会通过 一个数组进行映射,然后再将枚举转换成映射后的值进行比较。
枚举类
枚举类的实例对象是有限的
- 都继承了 Enum 父类
- 内部静态的成员常量
- 在静态代码块中,进行赋值
- 构造方法私有化
- values() 方法,获取当前类的所有枚举对象
- valueOf() 方法,获取名称为 name 对应的枚举对象(HashMap)
try-with-resources
方法重写时的桥接方法
匿名内部类
类加载
加载
- 将类的字节码 *载入方法区 *中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_miror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- itable 接口方法表
- 如果这个类还有父类没有加载,先加载父类
- 加载和链接可能是交替运行的
注意
- instanceKlass 这样的【元数据】是存储在方法区(1.8后的元空间内),但 _java_mirror 是存储在堆中
- 可以通过前面介绍的 HSDB 工具查看
Person.class 就是 java 类的镜像
链接
验证、准备、解析
- 验证:验证类是否符合 JVM规范,安全性检查
用UE 等支持二进制的编辑器修改 HelloWorld.class 的魔数,在控制台运行
-
准备:为 static 变量 分配空间,设置默认值
- static 变量在JDK 7 之前存储于 instanceKlass 末尾,从JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
-
解析:将常量池中的 符号引用 解析为 直接引用
因为链接过程中的解析需要把常量池中的符号引用 FiledRef 也就是代表类D的符号转换成直接引用 所以就会把类D加载进来
上面的代码,只加载了类,所以这里 Index=2 是未解析,后面的 Constant Value 对应的 D 类仅仅是一个符号
如果我们直接 new 了 类的对象
就会将符号地址,转换为 直接地址
初始化
<cinit>()V 方法
初始化即调用 <cinit>()V,虚拟机会保证这个类的【构造方法】的线程安全
<cinit>()V 方法:就是 static 静态代码块和静态成员默认值 合并为一个 <cinit>()V 方法
练习:
- a,b 都是静态常量(且是基本类型/字符串常量),在类链接的 准备 阶段 就已经赋值了,不会导致初始化
- c 是 包装类型,只能推迟到初始化阶段完成
上面的代码,只有调用 getInstance 方法,间接引用 内部类的静态属性时,静态内部类才会被初始化。
加载class类到方法区,然后下面的链接和初始化都是为了初始化方法区class类的成员变量,链接是初始化常量池字符引用为真正引用,初始化是给_mirror赋值
类加载器
启动类加载器:JAVA_HOME/jre/lib
扩展类加载器:JAVA_HOME/jre/lib/ext
应用程序类加载器:classpath
自定义类加载器:自定义
双亲委派机制
启动类加载器
查看类是由哪个类加载器加载的
null 代表启动类加载器加载器的(因为启动类加载器是 c++ 写的,不能直接访问到)
扩展类加载器
载 ext 下面有一个jar 包,里面有一个同名 G 类
发现是拓展类加载器加载的,而非应用类加载器
双亲委派模式
所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则
- 检查类是否已经加载
- 有上级的话,委派上级 loadClass
- 如果没有上级了(ExtClassLoader【Ext 的上级 Boot 不能直接获取出来】),则委派 BootstrapClassLoader
- 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
- 记录耗时
线程上下文类加载器
DriverManager 是 JAVA_HOME/jre/lib 包下的类
DriverManager 的 静态代码块,调用了 loadInitialDrivers() 方法
DriverManager 的类加载器是 启动类加载器,但是它应该搜索的是 “JAVA_HOME/jre/lib” 的类,这个包下没有 mysql 对应的驱动类
ClassLoad.getSystemLoader() 就是应用程序类加载器,说明 loadInitalDrivers() 方法里调用了 应用程序类加载器加载 Driver 类
jdk 需要在某些模式下打破双亲委派模式,有时候需要调用应用程序类加载器 加载,否则可能找不到这个类
SriverLoader<接口类型> allImpls = ServiceLoad.load(接口类型.class);
这里的接口类型可以传(java.sql.Driver),这样,就会找 META-INF/services/java.sql.Driver 文件,加载里面的实现类名,通过迭代器来获取对应的实现类
线程启动时,由JVM把应用程序类加载器赋值给线程的 ContextClassLoader(线程上下文加载器)
自定义类加载器
问问自己,什么时候需要自定义类加载器
- 1) 想加载非 classpath 随意路径中的类文件
- 2) 都是通过接口来使用实现,希望解耦时,常用在框架设计
- 3)这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于tomcat 容器
步骤:
- 继承 ClassLoader 父类
- 要遵从双亲委派机制,重写 findClass 方法
- 注意不是重写 loadClass 方法(重新了 ClassLoader 父类的 loadClass 方法),否则不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的loadClass 方法
示例:
准备好两个类文件放入E:myclasspath,它实现了 java.util.Map 接口,可以先反编译看一下:
- 同一个 类加载器对象,多次加载 同一个类,加载的是同一个类对象
- 不同 类加载器对象,加载 同一个类,对应的类对象,认为是不同的 类对象
为什么tomcat要打破双亲委派?
- 类隔离:Tomcat需要为 每个Web应用 提供 独立的类加载器(WebAppClassLoader),以确保不同应用之间的类库相互隔离。如果使用传统的双亲委派机制,所有应用将共享相同的类加载器,这可能导致类冲突
- 解决类冲突:不同Web应用可能依赖相同第三方库的不同版本。通过打破双亲委派机制,Tomcat确保每个应用优先加载自己目录下的类(如/WEB-INF/classes和/WEB-INF/lib),避免类冲突
- 热部署:打破双亲委派机制便于实现热部署功能,即在不重启服务器的情况下更新Web应用。通过自定义类加载器,Tomcat可以在运行时检测到应用的类文件变化,并加载新的类文件
Tomcat打破双亲委派机制是为了实现Web应用之间的类隔离、解决类冲突、支持热部署以及优化资源利用。这种机制使得Tomcat能够灵活地管理Web应用的类加载,同时保证了应用之间的独立性和安全性
运行期优化
分层编译
打印分层编译优化信息/关闭逃逸分析
逃逸分析:上面代码中的 new Object() 不会载循环外使用(C2 优化器优化)
方法内联
打印内联优化信息/禁用内联优化(指定特定方法/JDK其他方法可能也会内联)
字段优化
针对成员变量/静态成员变量 读写操作的优化
elements.length 首次读取会缓存起来 -> int[] local
- 局部变量缓存起来读取,不用再到 class 原数据那去找静态变量(但是如果不关方法内联,虚拟机会自动帮我们优化)
反射优化
DelegatingMethodAccessor 会直接调用 NativeMethodAcccessorImpl
从 第 17 次调用,虚拟机已经将反射方法调用变成了 普通方法调用
JMM(JAVA 内存模型)
概述
很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java Memory Model(JMM)的意思。
-
关于它的权威解释,请参考 https://download.oracle.com/otu-pub/jcp/memory_model-1.0-pfd-spec-oth-JSpec/memory_model-1_0-pfd-spec.pdf?AuthParam=1562811549_4d4994cbd5b59d964cd2907ea22ca08b
-
简单的说,JMM 定义了一套在 多线程 读写 共享数据 时(成员变量、数组)时,对数据的 可见性、有序性、和原子性 的 规则和保障
原子性
原子性在学习线程时讲过,下面来个例子简单回顾一下:
问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做5000次,结果是 0 吗?
Java 中对静态变量的自增,自减并不是原子操作
静态变量和局部变量自增的区别,看上面的字节指令可以发现
- 之前 局部变量自增 是用 inc 指令,直接在局部变量表中自增
- 静态变量 自增,需要准备一个常量 1 和 静态变量的值,放入操作数栈,通过 iadd 完成自增后,使用 putstatic 存入新值
主内存:静态变量共享值在 主内存中
工作内存:线程中在工作内存
多线程下,有工作内存 和 主内存进行数据交换
单线程顺序执行,不会出现问题
多线程下,线程交替运行,可能出现覆盖
解决办法
减少加锁解锁 次数:
红色的圈叫做对象的 monitor 区域,每个对象都有自己的一个 monitor 区域(只有 使用了 synchrionized 加锁的对象才有)
- owner:同一时刻只有一个线程成为 owner(只有当前对象的 owner 是空,当前线程才能使用 monitorenter 对当前对象进行锁定)
- EntryList:已经有 owner,则将线程加入 EntryList(当前owner 释放锁后, monitorexit 执行,此时会通知 EntryList 的所有线程,可以去争抢 owner)
- WaitSet:调用 wait 方法
可见性
运行后,发现线程不能退出循环
由于读取的是高速缓存的 旧值,所以 线程 读到的 run 一直是 true
解决办法
不能保证原子性
synchronized 即可以保证可见性,也可以保证原子性,属于重量级操作,性能低
为什么加了 System.out.println() 就能停止呢?
因为方法内部使用了 synchronized 同步关键字,可以防止当前线程在高速缓存中获取值,强制当前线程去 读取 主存中的值。(破坏了 JIT 优化)
有序性
除了上面的情况,还有可能是 0
解决办法:
用 volatile 修饰之后,线程从 volatile 关键字修饰的变量 读/写 数据的时候,已经不会发生指令重排序了
有序性理解
- new 关键字,给对象分配空间,将对象引用,放入操作数栈
- dup 赋值一份引用,操作数栈就有 两份对象引用
- invokespecial 最上面的一份引用消耗掉,调用 构造方法
- putstatic 另一份引用,交给 INSTANCE 变量,进行赋值(局部变量表?)
问题如上,用 volatile 修饰 INSTANCE 即可解决上面问题
happens-before
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结:
- 线程解锁 m 之前对变量的写,对于接下来对 m 加铁的其它线程对该变量的读可见
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
- 线程 start 前对变量的写,对该线程开始后的对该变量的读可见
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()(等待它结束)
- 线程 t1 打断 t2 (interrupt X 前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
也就是主线程中最后 sout(x) 和 t2 线程 if 条件里面的 sout(x),都是能读取到 x=10的(打断是一个标记,线程还会继续执行)
CAS
CSA 即 Compare and Swap,它体现的一种类观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:
但是我们平时不需要直接调用 Unsafe
乐观锁和悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思根:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:Atomiclnteger、AtomicBoolean等,它们底层就是采用CAS技术+volatile 来实现的。
可以使用 Atomiclnteger 改下之前的例子:
synchronized
Java Hotspot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。
- Mark Word 平时存储这个对象的 哈希码、分代年龄
- 当 加锁 时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID 等内容
轻量级锁(没有其他线程竞争时)
锁升级
每个线程的栈帧都会包含一个 锁记录的结构,内部可以存储锁定对象的 Mark Word(当前对 当前象加锁,则会将 MarkWord信息暂存到 锁记录结构 中,释放锁的时候,会取出来,恢复)
由于线程1 和 线程2 在获取锁的时间上,没有重叠,轻量级锁就已经够用
- 偏向锁:适用于单线程或低竞争场景,减少锁的开销,提高性能。
- 轻量级锁:适用于多线程场景,减少锁的开销,但需要处理多线程竞争。
锁升级:
- 偏向锁:如果其他线程尝试获取锁,偏向锁会 撤销 并 升级 为 轻量级锁或重量级锁。
- 轻量级锁:如果多个线程 竞争 锁,轻量级锁会升级为 重量级锁。
锁膨胀(偏向锁->轻量级->重量级)(可以唤醒其他线程)
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行 锁膨胀,将轻量级锁变为重量级锁。
如果当前有线程在使用共享锁对象,则需要锁升级,将 00轻量级锁 升级为 10 重量级锁,代表现在已经有其他线程在竞争锁了(需要通过特定方式释放,释放重量锁,唤起阻塞线程竞争)
重量级锁(竞争会出现自旋)
重量级铁竞争的时候,还可以使用自旋来进行优化,如果当前线程 自旋 成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在Java 6之后自旋铁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- 自旋会占用CPU时间,单核 CPU 自旋就是浪费,多核CPU 自旋才能发挥优势。
- 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
- Java 7 之后不能控制是否开启 自旋 功能
线程1加锁成功,此时线程2过来尝试获取 monitor,线程 2 不会马上阻塞(阻塞/唤醒时间消耗长)
不会立马阻塞,会自旋重试几次,如果此时 线程1 释放锁了,则它立马获取锁对象
多核 cpu,才能 自旋
偏向锁(重入用)
偏向锁是一种锁的状态,它的核心思想是 偏向于第一个获取锁的线程。如果在锁的生命周期内,只有一个线程访问该锁,那么这个线程可以 无锁 地执行同步代码块,从而减少锁的开销。
轻量级锁在没有竞争时(就自己这个线程),每次 重入 仍然需要执行 CAS操作。Java 6中引入了 偏向锁 来做进一步优化:只有第一次使用 CAS 将 线程 ID 设置到对象的 Mark Word 头,之后发现这个 线程ID 是自己的就表示没有竞争,不用重新 CAS。
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的 hashCode 也会撤销偏向锁(因为无锁状态下,对象头里存储的是对象的 hashCode,但是偏向锁的时候,存储了 线程ID, hashCode 无法存储,需撤销锁并升级,其他线程访问 对象 hashCode 时,在对象头找不到,所以会撤销。。)
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
- 撒销偏向和重偏向都是批量进行的,以 类 为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用-XX:-UseBiasedLocking 禁用偏向锁
其他优化