目录
一.引言
1.什么是JVM
JVM:java virtual machine(java虚拟机),Java程序的运行环境,是二进制字节码运行环境
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界检查
- 使用虚方法调用的机制实现多态
比较:jvm jre jdk
基础类库:集合类,线程类,IO类等
编译工具:javac,javap
应用服务器工具:tomcat
2.学习路线
二.内存结构
1.程序计数器
1.1定义
1.2作用
- java源代码经过编译后成为二进制字节码的jvm指令
- jvm指令经过解释器成为机器码
- 机器码可以被cpu执行
而程序计数器就会记住下一条需要执行的的jvm指令的序号
特点:
- 线程私有的(每个线程都会有自己的程序计数器)
- 不会存在内存溢出问题
2.虚拟机栈
2.1定义
栈:线程运行的内存空间
栈帧:每个方法运行时需要的内存(存放参数,局部变量,返回地址)
- 每个线程运行时所需的内存,成为虚拟机栈
- 每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的方法。
问题辨析:
垃圾回收是否涉及栈内存?
- 栈帧内存在每次方法执行完毕后都会弹出,垃圾回收是回收的堆内存。
栈内存分配越大越好吗?
- 不是,在物理内存一定的情况下,栈内存越大,可以运行的线程数量越少,从而会影响程序的运行速度。栈内存大小只决定连续调用方法的数量。例如:递归调用可能造成栈溢出问题。
方法内的局部变量是否线程安全?
- 如果方法内部的局部变量没有逃离方法的作用范围,他是线程安全的。
- 如果局部变量引用了对象(传进来的参数),并逃离了(return出)方法的作用范围,则需要考虑线程安全。(因为无法考虑方法之外,变量是否被其他线程调用)
- static修饰的变量不是放在栈中的,是线程共享的,是线程不安全的。
2.2栈内存溢出(StackOverflowError)
造成原因:
- 栈帧过多导致栈内存溢出(错误的递归调用)
- 栈帧过大导致栈内存溢出(一个方法中的局部变量过多,出现几率小)
2.3线程运行诊断
案例1:cpu占用过多
定位步骤:
- 用top命令定位哪个进程对cpu占用过高
- ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)。pid,tid,%cpu是选择查看的列。
- 命令jstack 进程id。 可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号
执行top命令:
jstack 进程id命令
注:nid是pid的16进制转换
案例2:程序运行很长时间没有结果
定位同上,执行jstack 进程id命令。
查询结果是线程死锁。
3.本地方法栈
给本地方法运行提供内存空间。
本地方法:用c或c++写的系统本地的方法(native修饰的),例如:object类中的clone(),hashCode(),notify()等
4.堆
4.1定义
通过new关键字创建的对象都会使用堆内存。
特点:
- 是线程共享的,堆中的对象都需要考虑线程安全问题。
- 有垃圾回收机制
4.2堆内存溢出(OutOfMemoryError)
代码演示:
package com.erp.payroll.test.VO;
import java.util.ArrayList;
import java.util.List;
/**
* @author guang
* @version V1.0
* @Package com.erp.payroll.test.VO
* @date 2022/2/17 15:29
*/
public class JvmDemo {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String s = "haha";
while (true) {
list.add(s);
s = s + s;
i++;
}
} catch (Throwable throwable) {
throwable.printStackTrace();
System.out.println(i);
}
}
}
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at com.erp.payroll.test.VO.JvmDemo.main(JvmDemo.java:20)
26
堆空间内存也可以通过-Xmx 设置 ,例如:设置8m -Xmx8m。
4.3堆内存诊断
工具:
1.jps工具
- 查看当前系统中有哪些java进程
2.jmap工具(某一时刻)
- 查看堆内存占用情况
3.jconsole工具
- 图形界面的,多功能的检测工具,可以连续监测
演示:当执行1时候,堆中没有过多的内存被占用,但是在创建bytes后,就会增加10mb,在执行了2之后,会调用gc清理,堆内存占用又会减少。
package com.erp.payroll.test.VO;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author guang
* @version V1.0
* @Package com.erp.payroll.test.VO
* @date 2022/2/17 15:29
*/
public class JvmDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println("1=============");
TimeUnit.SECONDS.sleep(30);
byte[] bytes = new byte[1024 * 1024 * 10];//10mb
System.out.println("2=============");
TimeUnit.SECONDS.sleep(30);
bytes = null;
System.gc();
System.out.println("3=============");
TimeUnit.SECONDS.sleep(50);
}
}
执行命令
F:\***\erp-payroll>jps
得到
8844 JvmDemo
打印1=============之后
执行
F:\***\erp-payroll>jmap -heap 8844
堆内存使用:18187392
Heap Usage:
PS Young Generation
Eden Space:
capacity = 58720256 (56.0MB)
used = 18187392 (17.3448486328125MB)
free = 40532864 (38.6551513671875MB)
30.972943987165177% used
打印2=============之后,执行
Heap Usage:
PS Young Generation
Eden Space:
capacity = 58720256 (56.0MB)
used = 28673168 (27.344863891601562MB)
free = 30047088 (28.655136108398438MB)
48.83011409214565% used
打印3=============之后,执行
Heap Usage:
PS Young Generation
Eden Space:
capacity = 58720256 (56.0MB)
used = 2348848 (2.2400360107421875MB)
free = 56371408 (53.75996398925781MB)
4.000064304896763% used
jconsole工具使用
启动main方法,执行命令
F:\***\erp-payroll>jconsole
弹出视图工具
动态查看堆内存使用情况
案例:
垃圾回收后,内存占用仍然很高(详情见视频)
通过jsp可以看出新生代堆内存清理后,老年代还在被占用(对象被引用,无法释放)
另一个视图工具
执行命令:(高版本jdk没有内置,需自主安装)
F:\****\erp-payroll>jvisualvm
5.方法区
5.1定义
方法区是所有线程共享的区域。存储了跟类的结构相关的信息,有成员变量,方法数据,成员方法,构造方法,运行时常量池等
方法区是在类启动时被创建。
5.2组成
不同版本的jdk内存结构有所不同
1.6中方法区占用了堆内存;字符串常量池放在了方法中的运行时常量池中
1.8之后方法区放在了本地内存中;字符串常量池放进了堆中。
5.3方法区内存溢出
1.8之后方法区放在本地内存中,所以内存溢出不太容易出现,可以把相关参数设置较小再进行测试。
元空间参数设置 -XX:MaxMetaspaceSize=8m
永生代参数设置 -XX:MaxPermSize=8m
并且两种版本号内存溢出报错也有所不同
元空间内存溢出:OutOfMemoryError:Metaspace
永生代内存溢出:OutOfMemoryError:PermGen space
package com.erp.payroll.test.VO;
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author guang
* @version V1.0
* @Package com.erp.payroll.test.VO
* @date 2022/2/17 15:29
*/
/**
* 元空间内存溢出:OutOfMemoryError:Metaspace
* 永生代内存溢出:OutOfMemoryError:PermGen space
* 元空间参数设置 -XX:MaxMetaspaceSize=8m
* 永生代参数设置 -XX:MaxPermSize=8m
*/
public class JvmDemo extends ClassLoader {// 可以用来加载类的二进制字节码
public static void main(String[] args) throws InterruptedException {
int j = 0;
try {
JvmDemo test = new JvmDemo();
for (int i = 0; i < 10000; i++, j++) {
//ClassWriter作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
//版本号,public,类名,包名,父类,接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i,
null, "java/lang/Object", null);
//返回byte[]
byte[] code = cw.toByteArray();
//执行类加载器
test.defineClass("Class" + i, code, 0, code.length);//Class对象
}
} finally {
System.out.println(j);
}
}
}
5.4运行时常量池
演示查看字节码详情中的常量池:
代码:
package com.erp.payroll.test.VO;
/**
* @author guang
* @version V1.0
* @Package com.erp.payroll.test.VO
* @date 2022/2/18 10:53
*/
//二进制字节码(类的基本信息,常量池,类方法定义,包含了虚拟机指令)
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world!");
}
}
操作指令:
1.先cd到文件的上一级目录
2.使用javac编译出字节码
***test\VO>javac HelloWorld.java
3.使用javap查看字节码详情
****test\VO>javap -v HelloWorld.class
二进制字节码详情:(类的基本信息,常量池,类方法定义,包含了虚拟机指令)
类的基本信息:
Last modified 2022-2-18; size 450 bytes
MD5 checksum 284a6f82e2f72e0f5adafd276b197cef
Compiled from "HelloWorld.java"
public class com.erp.payroll.test.VO.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
运行时常量池:
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // hello world!
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // com/erp/payroll/test/VO/HelloWorld
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 HelloWorld.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 hello world!
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 com/erp/payroll/test/VO/HelloWorld
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
类的方法定义:包含了虚拟机指令
{
public com.erp.payroll.test.VO.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 9: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 11: 0
line 12: 8
}
SourceFile: "HelloWorld.java"
虚拟机指令:
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
常量池作用:
例如行号3:对应的#3,表示进入运行时常量池中的#3进行翻译
运行时常量池中的#3对应的#18,继续翻译
#18对应 Utf8 hello world!
表示对应的是字符串形式的 hello world!
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量信息。
运行时常量池,常量池是*.class文件中的,当该类被加载,他的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。
5.5StringTable
面试题:
package com.erp.payroll.test.VO;
import java.sql.SQLOutput;
/**
* @author guang
* @version V1.0
* @Package com.erp.payroll.test.VO
* @date 2022/2/18 14:02
*/
public class StringDemo {
public static void main(String[] args) {
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);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x1 = new String("c") + new String("d");
String x2 = "cd";
x1.intern();
//问
System.out.println(x1 == x2);
//调换最后两行代码位置呢,如果是jdk1.6呢
String x3 = new String("c") + new String("d");
x3.intern();
String x4 = "cd";
System.out.println(x1 == x2);
}
}
常量池和串池的关系:
public class StringDemo2 {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}
查看二进制字节码:
Last modified 2022-2-18; size 334 bytes
MD5 checksum 7b08065901c43270c4a91a4d85f35540
Compiled from "StringDemo2.java"
public class com.erp.payroll.test.VO.StringDemo2
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = String #16 // a
#3 = String #17 // b
#4 = String #18 // ab
#5 = Class #19 // com/erp/payroll/test/VO/StringDemo2
#6 = Class #20 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 StringDemo2.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Utf8 a
#17 = Utf8 b
#18 = Utf8 ab
#19 = Utf8 com/erp/payroll/test/VO/StringDemo2
#20 = Utf8 java/lang/Object
{
public com.erp.payroll.test.VO.StringDemo2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 9: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return
LineNumberTable:
line 11: 0
line 12: 3
line 13: 6
line 14: 9
}
SourceFile: "StringDemo2.java"
常量池中的信息都会被加载到运行时常量池中(这时a,b,ab都是常量池中的符号,还没变为java字符串对象)。
当执行到引用的代码上才会成为java字符串对象,
String s1 = "a";
0: ldc #2 // String a
#2 = String #16 // a
#16 = Utf8 a
ldc #2会把a符号变成“a”字符串对象,在这个过程中会先准备串池StringTable[ ]的空间,并先以a为key查询串池中是否存在(不存在则放入串池)。 StringTable[ ]是hashTable结构
s4执行时做了什么:
String s4 = s1 + s2;
二进制字节码:
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBu
ilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBu
ilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
在这个过程中它做了这些事情:new StringBuilder( ).append("a").append("b").toString( )
但是查看toString()源码会发现,它是new了一个新的String(new String(“ab”))。
所以s4指向了一个堆内的地址。s3不等于s4。
s5:
String s5 = "a" + "b";
29: ldc #4 // String ab
31: astore 5
它是直接拿到了字符串ab。与String s3 = “ab”相同 (是在编译期确定的为ab的)
5.6StringTable特性
- 常量池中的字符串仅是符号,第一次用到时才会变为对象
- 利用串池的机制,来避免重复创建创建字符串对象
- 字符串变量拼接的原理是StringBuilder(1.8之后),存在于堆中
- 字符串常量拼接的原理是编译期优化,存在于串池中
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池。
- 1.8 版本之后将字符串对象放到串池中。如果串池中有,则不会放入,如果没有,则会放入,并将串池中的对象返回 。
- 1.6版本将字符串对象放到串池中。如果串池中有,则不会放入,如果没有,则会把此对象复制一份放入(浅克隆地址不同),并将串池中的对象返回 。
案例1:
String s = new String("a") + new String("b");
动态的拼接是存在于堆中的,相当于new了一个“ab”
String s1 = s.intern();
此方法会将s(“ab”)放到串池中。如果串池中有“ab”,则不会放入,如果没有,则会放入(s的地址指向串池中“ab”),并将串池中的对象返回 。s1一定是串池中的对象。
结论:
System.out.println(s=="ab");//true
System.out.println(s1=="ab");//true
但是如果没有 String s1 = s.intern(); 则s==“ab”为false(ab是串池中的对象,s是堆中的对象)。
案例2:
String x = "ab";
String s = new String("a") + new String("b");
String s1 = s.intern();
System.out.println(s=="ab");
System.out.println(s1=="ab");
此代码与案例1代码不同之处在于在执行到x=“ab”时就已经将ab放进了串池中,所以在执行到s1时候将s(在堆中)放进串池,发现串池中已经存在(则没有放进串池,地址就没办法指向串池),则s还是堆中的ab。s1还是指向的串池中的ab。
回看面试题:
package com.erp.payroll.test.VO;
import java.sql.SQLOutput;
/**
* @author guang
* @version V1.0
* @Package com.erp.payroll.test.VO
* @date 2022/2/18 14:02
*/
public class StringDemo {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";//常量拼接编译期就优化直接指向串池
String s4 = s1 + s2;//动态拼接相当于在堆中new了一个ab
String s5 = "ab";//常量池
String s6 = s4.intern();//如果串池中没有则将s4放入串池。有则不放入。s6一定是串池中对象
//问
System.out.println(s3 == s4);//false,s4在堆中
System.out.println(s3 == s5);//true,都在串池中
System.out.println(s3 == s6);//true,都在串池中
String x1 = new String("c") + new String("d");
String x2 = "cd";
x1.intern();
//问
System.out.println(x1 == x2);//false,x1在堆中,x2在串池中
//调换最后两行代码位置呢,如果是jdk1.6呢
String x3 = new String("e") + new String("f");
x3.intern();//1.8将x3放入串池,如果没有则放入(堆与串池同地址),有则不放(与串池对象不同)
//1.6将x3浅复制放进串池,x3与串池对象一定不同。
String x4 = "ef";
System.out.println(x3 == x4);//true
}
}
5.7StringTable位置
5.8StringTable垃圾收
因StringTable引起堆内存不够用时,垃圾回收机制也会对StringTable进行垃圾回收。
5.9StringTable性能调优
StringTable的底层是hash表。
所以hash表的桶的个数对StringTable的性能息息相关。
- 在配置参数中增加桶的个数(-XX:StringTableSize=桶个数)有助于性能的增加。
- 如果有大量的字符串运用,并且可能有大量重复时,可以使用str.intern()使字符串入池,减少堆内存的使用
6.直接内存
6.1定义
定义:
- 直接内存是操作系统管理的内存
- 常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
6.2直接内存基本使用
IO读写
使用DirectBuffer
直接内存是操作系统和java代码都可以访问的一块区域,无需将代码从系统内存复制到java堆内存,从而提高了效率。(NIO是对DirectBuffer 直接内存做了进一步优化)
6.3直接内存回收原理
public class Code_06_DirectMemoryTest {
public static int _1GB = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
// method();
method1();
}
// 演示 直接内存 是被 unsafe 创建与回收
private static void method1() throws IOException, NoSuchFieldException, IllegalAccessException {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe)field.get(Unsafe.class);
long base = unsafe.allocateMemory(_1GB);
unsafe.setMemory(base,_1GB, (byte)0);
System.in.read();
unsafe.freeMemory(base);
System.in.read();
}
// 演示 直接内存被 释放
private static void method() throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
System.out.println("分配完毕");
System.in.read();
System.out.println("开始释放");
byteBuffer = null;
System.gc(); // 手动 gc
System.in.read();
}
}
直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过unsafe.freeMemory 来手动释放。
第一步:allocateDirect 的实现
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
allocateDirect方法是创建了一个DirectByteBuffer对象。
第二步:DirectByteBuffer 类
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size); // 申请内存
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); // 通过虚引用,来实现直接内存的释放,this为虚引用的实际对象, 第二个参数是一个回调,实现了 runnable 接口,run 方法中通过 unsafe 释放内存。
att = null;
}
类中包含了申请内存的过程并且还调用了一个 Cleaner 的 create 方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer )被回收以后,就会调用 Cleaner 的 clean 方法,来清除直接内存中占用的内存。
public void clean() {
if (remove(this)) {
try {
// 都用函数的 run 方法, 释放内存
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
可以看到关键的一行代码, this.thunk.run(),thunk 是 Runnable 对象。run 方法就是回调 Deallocator 中的 run 方法。
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 释放内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
直接内存的回收机制总结
- 使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用freeMemory 方法
- ByteBuffer 的实现内部使用了 Cleaner(虚引用)来检测 ByteBuffer 。一旦ByteBuffer 被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法调用 freeMemory 来释放内存
7垃圾回收
7.1判别对象可以回收
7.1.1引用计数法
一个对象被另一个对象引用时,引用计数就会加1,不被此引用时,计数就会减1。当引用计数值为0时,说明对象没有被引用,就可以被垃圾回收器回收。
但是这种方法还是有一种弊端,如果出现两个类循环引用时就会导致计数器永远不会为0,使两个对象都无法被释放。
7.1.2可达性分析算法
- JVM中垃圾回收器就是通过可达分析算法来探索所有存活的对象
- 过程主要是扫描堆中的对象,看是否是GC Root对象所链接的对象,如果没有与根类相连,则表示可以回收,否则不会被回收。(这个根一般是不会被回收的,通过这个根能找到的就是被引用的,找不到的就回收)
- 可作为GC Root的对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的Native方法)引用的对象
7.1.3四种引用
- 强引用:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
- 软引用(SoftReference):仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象;可以配合引用队列来释放软引用自身
- 弱引用(WeakReference):仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象;可以配合引用队列来释放弱引用自身
- 虚引用(PhantomReference):必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
- 终结器引用(FinalReference):无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。Finalizer 线程优先级很低,可能会造成长时间不被回收
补充:虚引用和引用队列同时存在,betybuffer没有强行用了 虚引用就会进入虚引用队列,一个检查虚引用队列的线程会启用unsafe把直接内存释放掉,采用虚引用的目的就是释放直接引用
软引用:Object a = new Object(); SoftReference<Object> softReference = new SoftReference<>(a);
弱引用:Object a = new Object(); WeakReference<byte[]> weakReference = new WeakReference<>(a);
软引用演示:
/**
* 演示 软引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Code_08_SoftReferenceTest {
public static int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) throws IOException {
method2();
}
// 设置 -Xmx20m , 演示堆内存不足,
public static void method1() throws IOException {
ArrayList<byte[]> list = new ArrayList<>();
for(int i = 0; i < 5; i++) {
list.add(new byte[_4MB]);
}
System.in.read();
}
// 演示 软引用
public static void method2() throws IOException {
ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
for(int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束:" + list.size());
for(SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
}
method1 方法解析:
首先会设置一个堆内存的大小为 20m,然后运行 mehtod1 方法,会抛异常,堆内存不足,因为 mehtod1 中的 list 都是强引用。
method2 方法解析:
在 list 集合中存放了 软引用对象,当内存不足时,会触发 full gc,将软引用的对象回收。细节如图:
上面的代码中,当软引用引用的对象被回收了,但是软引用还存在,所以,一般软引用需要搭配一个引用队列一起使用。
修改 method2 如下:
// 演示 软引用 搭配引用队列
public static void method3() throws IOException {
ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for(int i = 0; i < 5; i++) {
// 关联了引用队列,当软引用所关联的 byte[] 被回收时,软引用自己会加入到 queue 中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while(poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("=====================");
for(SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
这样对应的软引用对象也会被清理。
弱引用演示:
public class Code_09_WeakReferenceTest {
public static void main(String[] args) {
// method1();
method2();
}
public static int _4MB = 4 * 1024 *1024;
// 演示 弱引用
public static void method1() {
List<WeakReference<byte[]>> list = new ArrayList<>();
for(int i = 0; i < 10; i++) {
WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB]);
list.add(weakReference);
for(WeakReference<byte[]> wake : list) {
System.out.print(wake.get() + ",");
}
System.out.println();
}
}
// 演示 弱引用搭配 引用队列
public static void method2() {
List<WeakReference<byte[]>> list = new ArrayList<>();
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for(int i = 0; i < 9; i++) {
WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB], queue);
list.add(weakReference);
for(WeakReference<byte[]> wake : list) {
System.out.print(wake.get() + ",");
}
System.out.println();
}
System.out.println("===========================================");
Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}
for(WeakReference<byte[]> wake : list) {
System.out.print(wake.get() + ",");
}
}
}
7.2垃圾回收算法
JVM不是使用单一的回收算法,而是结合使用这些回收算法,协同工作。
7.2.1标记清除算法
所谓的清除不是直接清零内存,而是把被清除对象占用的起始内存地址放到空闲列表中,下次被分配的对象会从内存地址中找,找到空间合适的进行覆盖。
标记清除算法,垃圾回收速度快,但是容易产生内存碎片(大内存对象(数组)无法放入某一个内存碎片)。
7.2.2标记整理算法
整理的是内存碎片,不会产生内存碎片
整理时内存地址是进行变化的,相对应对象的引用也会有所变化,所以涉及改变的东西比较多,会影响垃圾回收速度。
7.2.3复制算法
被Root引用的对象会先复制到事先准备好的TO内存中
清空FROM的内存空间
交换FROM和TO两块内存空间。
复制算法没有内存碎片但是会占用双倍的内存空间。
7.3分代垃圾回收
长时间使用的对象放在老年代中,容易被丢弃的对象回放到新生代当中。新生代中垃圾回收频繁。
当添加一个大对象时(超过新生代总内存),新生代无法放入,就会直接放入老年代。
伊甸园放不下时候会发生一次MinorGC(标记算法),然后把存活的对象再放进幸存区To中,并将幸存这些存活的对象幸存寿命加1(复制算法),并将幸存区From,To进行交换。
MinorGC会引发stop the world。(其他用户线程停止动作,等垃圾回收结束后,才会恢复),因为对象移动时会造成地址的改变。如果不停止工作会造成对象引用找不到。
然后第二次向伊甸园添加数据。 伊甸园放不下时候又会发生一次MinorGC(标记算法),但是本次会将幸存区From也进行MinorGC。
并继续执行存活的对象幸存寿命加1(复制算法),将幸存区From,To进行交换。
当幸存寿命达到阈值(最大是15,4bit)时,就会将该对象晋升到老年代。
当老年代空间不足,会先触发一次MinorGC,如果执行完新生代同样空间不足时就会发生一次FullGC(对新生代,老年代都进行回收)。
FullGC也会引起stop to world,并且时间会更长(标记整理算法)。
FullGC执行后如果空间还是不足,就会报错(OutOfMemoryError: Java heap space)
7.3.1相关VM参数
public class Code_10_GCTest {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
// -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_6MB]);
list.add(new byte[_512KB]);
list.add(new byte[_6MB]);
list.add(new byte[_512KB]);
list.add(new byte[_6MB]);
}
}
提前设置vm参数后,可以执行代码查看新生代和老年代的情况,什么时候触发 minor gc,什么时候触发 full gc 等情况。
当一个线程抛出OOM异常(OutOfMemoryError: Java heap space)后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行。
7.4垃圾回收器
7.4.1串行垃圾回收器
- 底层单线程的垃圾回收器
- 使用场景:堆内存较小,适合个人电脑
-XX:+UseSerialGC=serial + serialOld
安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象。因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态
Serial 收集器
Serial 收集器是最基本的、发展历史最悠久的收集器
特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)!
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本
特点:同样是单线程收集器,采用标记-整理算法
7.4.2吞吐量优先垃圾回收器
- 多线程垃圾回收器
- 使用场景:堆内存较大,需要多核cpu支持
- 单位时间内stop to world 时间最短(例如:单位时间是1h,在1h内发生两次垃圾回收,每次0.2s,共0.4s。达到单位时间最短,0.4秒)
吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。
XX:MaxGCPauseMillis=ms 控制最大的垃圾收集停顿时间(默认200ms)
XX:GCTimeRatio=rario 直接设置吞吐量的大小
Parallel Scavenge 收集器
与吞吐量关系密切,故也称为吞吐量优先收集器
特点:属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与 ParNew 收集器类似)
Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本
特点:多线程,采用标记-整理算法(老年代没有幸存区)
7.4.3响应时间优先垃圾回收器
- 多线程垃圾回收器
- 使用场景:堆内存较小,需要多核cpu支持
- 尽可能让单次stop to world 时间最短(例如:单位时间是1h,在1h内发生5次垃圾回收,每次0.1s,共0.5s。达到单次最短0.1秒,不考虑单位时间次数)
单位时间内垃圾回收所花费时间对于吞吐量,垃圾回收的速度对应响应时间
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark
CMS 收集器
Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务
CMS 收集器的运行过程分为下列4步:
初始标记:标记 GC Roots 能直接到的对象。速度很快但是仍存在 Stop The World 问题。
并发标记:进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World 问题
并发清除:对标记的对象进行清除回收,清除的过程中,可能仍然会有新的垃圾产生,这些垃圾就叫浮动垃圾,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,就会退化为 serial Old 收集器,将老年代垃圾进行标记-整理,当然这也是很耗费时间的!
CMS 收集器的内存回收过程是与用户线程一起并发执行的,可以搭配 ParNew 收集器(多线程,新生代,复制算法)与 Serial Old 收集器(单线程,老年代,标记-整理算法)使用。
7.4.4 G1
定义: Garbage First
JDK9默认的垃圾回收器
适用场景:
- 同时注重吞吐量和低延迟(响应时间),默认暂停(STW)目标200ms。
- 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域(内存较小时和cms速度相差不大)
- 整体上是标记-整理算法,两个区域之间是复制算法
相关参数:
JDK8 并不是默认开启的,所需要参数开启
-XX:+UseG1GC 启动开关
-XX:G1HeapRegionSize=size
-XX:MaxGCPauseMillis=time
1)G1垃圾回收阶段
新生代垃圾回收 -> 新生代垃圾回收+并发标记(老年代达到阈值) -> 混合收集(对伊甸园,幸存区,老年代都进行规模较大的垃圾回收) -> 新生代垃圾回收(又重新开始)
2)Young Collection
E:伊甸园
伊甸园回收时会STW
S:幸存区
伊甸园清理后内存依然紧张就会将数据复制到幸存区。
O: 老年代
又经过一段时间幸存区内存也不足,并且幸存年龄达到一定值后就会执行垃圾回收,并将其放进老年代。其中伊甸园中的数据和幸存区不够年龄的依然会复制到幸存区,增加幸存年龄。
3)Young Collection + CM
- 在Yong GC时就会进行对GC Root的初始标记。(并不会占用并发标记的时间,发生在新生代垃圾回收过程中)
- 老年代占用堆内存空间比例达到阈值时,进行并标记(不会STW)。由XX:InitiatingHeapOccupancyPercent=percent (默认45%)JVM参数决定
4)Mixed Collection
会对伊甸园(E),幸存区(S),老年代(O)进行全面的垃圾回收。
- 最终标记(Remark)会STW
- 拷贝存活(Evacuation)会STW
老年代的垃圾回收是比较浪费时间的,所以并不会对全部的老年代进行垃圾回收,它会根据设置的MaxGCPauseMills停顿时间对一部分(快被占满的区域)老年代回收。
老年代回收时也会做复制操作,不被回收的被复制到一个老年代区域
5)Full GC
垃圾回收器 | 新生代内存不足发生垃圾回收 | 老年代内存不足发生垃圾回收 |
Parallel GC | minor gc | full gc |
Serial GC | minor gc | full gc |
CMS GC | minor gc | 1.如果并发垃圾收集速度快于垃圾产生速度则是并发垃圾收集 2.如果并发垃圾收集速度慢于垃圾产生速度则是full gc |
G1 | minor gc |
6)Young Collection 跨代引用
有一部分Root对象放在老年代,但是老年代数据比较多,遍历一次会耗费大量时间。
所以就采用对老年代的区域细分,分成一个个card(512B),如果card中有对象引用了新生代的对象,就会标记为脏card。这样下次GC Root遍历时就不会遍历所有的老年代,只关注脏card。
新生代被引用的对象会在Remembered Set中记录被引用的信息(脏card),在垃圾回收时会先读取Remembered Set中的记录,找到对应的GC Root,遍历找到存活对象,清理其它对象。
每次引用变更时会对脏card进行更新,这个过程是一个异步操作,不会立刻更新,会先将更新指令放进脏card队列中,然后会有一个线程进行更新操作。
7)Remark(重新标记阶段)
并发标记过程
问题:C对象先是被B对象引用,但是在并发标记过程中C对象断开了对B对象的引用,这时B对象处理完成,C对象就会呈现被处理的状态,但是在此过程中有用户线程对C对象操作,引用了A对象。这时C就不应该被处理。
解决:并发标记期间对象引用关系发生变化就加入队列,在重新标记时再次处理,重新标记时触发STW,对象的引用关系不会再改变
过程:
- 之前 C 未被引用,这时 A 引用了 C ,就会给 C 加一个写屏障,写屏障的指令会被执行,将 C 放入一个队列当中,并将 C 变为 处理中状态
- 在并发标记阶段结束以后,重新标记阶段会 STW ,然后将放在该队列中的对象重新处理,发现有强引用引用它,就会处理它,由灰色变成黑色。
8)JDK 8u20 字符串去重
例:
String s1 = new String("hello");//char[]{'h','e','l','l','o'}
String s2 = new String("hello");//char[]{'h','e','l','l','o'}
创建两个String对象,其底部是创建了两个相同的char数组。
去重过程:
- 将所有新分配的字符串(底层是 char[] )放入一个队列
- 当新生代回收时,G1 并发检查是否有重复的字符串
- 如果字符串的值一样,就让他们引用同一个字符串对象
注意:其与 String.intern() 的区别
- String.intern() 关注的是字符串对象(两个对象还是对应的两个地址,但是底层指向的是同一个数组)
- 字符串去重关注的是 char[]
- 在 JVM 内部,使用了不同的字符串标
优点与缺点
- 节省了大量内存
- 新生代回收时间略微增加,导致略微多占用 CPU
JVM参数(默认开启)
-XX:+UseStringDeduplication
9)JDK 8u40 并发标记类卸载
JVM 参数默认启用
在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
10)JDK 8u60 回收巨型对象
- 一个对象大于region的一半时(1.5区域),称为巨型对象。(下图下面H占用两个region)
- G1不会对巨型对象进行拷贝
- 回收时会被优先考虑
- G1会跟踪老年代对巨型对象的所有incoming引用,这样老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时被处理掉。
上面H在不被老年代所引用时,就会在下一次新生代垃圾回收时被处理掉。
11)JDK 9 并发标记起始时间的调整
- 并发标记必须在堆空间占满前完成,否则退化为 FulGC
- JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent
- JDK 9 可以动态调整
- -XX:InitiatingHeapOccupancyPercent 用来设置初始值
- 进行数据采样并动态调整
- 总会添加一个安全的空挡空间
7.5垃圾回收调优
预备知识:
- 掌握相关的VM参数,会基本的空间调整(官网:oracle.com/en/java/javase,或者执行命令)
- 掌握相关工具
- 调优受很多因素影响(环境,应用)
查看虚拟机参数命令:
D:\JavaJDK1.8\bin\java -XX:+PrintFlagsFinal -version | findstr "GC"
7.5.1调优领域
- 内存调优(JVM)
- 锁竞争调优
- cpu占用
- IO
7.5.2确定目标
- 是优先低延迟(互联网项目)还是高吞吐量(科学运算),选择合适的回收器
- CMS、G1、ZGC(低延迟)
- ParallelGC(高吞吐量)
7.5.3不发生GC最快
- 数据是不是太多?
- 例如:(select * from 大表)
- 可以根据实际情况 limit n,或只查询自己需要的。
- 数据表示是否太臃肿?
- 对象图(用到什么查什么)
- 对象大小 (最小的Object占16字节,Integer 24字节,int 4字节)基本类型占用内存最少
- 是否存在内存泄漏
- static Map map =(GC无法回收)
- 使用软引用或弱引用引用Map
- 第三方缓存实现(redis)
7.5.4新生代调优
JVM调优优先考虑新生代调优。(老年代垃圾清理在新生代之后)
新生代特点:
- 所有的new操作的内存分配非常廉价(创建快)
- 每个线程都会在堆中分配一个私有内存TLAB(thread-local allocation buffer),new对象时会先检查TLAB中有没有空间,并优先存放在TLAB中。
- 死亡对象的回收代价为零
- 大部分对象用过即死
- Minor GC 的时间远远低于Full GC
新生代调优:是不是新生代内存越大越好?(建议:堆内存的1/4~1/2)
- 新生代内存太小:频繁触发Minor GC,会STW
- 新生代内存太大:老年代内存占比会降低,会更加频繁的触发Full GC。而且每次触发Minor GC清理新生代内存时会花费更多的时间。也可能使吞吐量降低。
结论:新生代内存设置空间还是尽可能的大。(容纳所有【并发量*(请求--响应)】的数据)
幸存区:大到能保留【当前活跃对象+需要晋升对象】
- 如果幸存区内存较小JVM就会动态的设置对象晋升到老年代的阈值,晋升到老年代之后,只有在Full GC之后才能被清理。
问题:新生代晋升老年代时,让长时间存活对象尽快晋升,但是又不想让存活时间较短的对象晋升到老年代。所以需要合理的配置晋升阈值。
-XX:MaxTenuringThreshold=threshold
-XX:+PrintTenuringDistrubution
7.5.5老年代调优
以CMS为例
- CMS的老年代内存越大越好
- 先不尝试做调优,如果没有Full GC就暂不调优,否则先尝试调优新生代
- 观察发生Full GC时老年代内存占用,将老年代的内存预设调大1/4~1/3
-XX:CMSInitiatingOccupancyFraction=percent
7.5.6案例
案例1:Full GC和Minor GC频繁
- 原因分析:业务高峰期,大量对象被创建,新生代内存很快被占用,幸存区占满后,晋升阈值就会降低,大量生存周期短的对象进入老年代
- 解决:增大新生代内存
案例2:请求高峰期发生Full GC,单次暂停时间特别长(CMS)
- 原因分析:CMS耗费时间主要是在重新标记过程中,重新标记会扫描整个堆内存,并且是STW。如果是高峰期就会浪费大量时间。
- 解决:在重新标记之前,先对新生代做一次垃圾回收。
- 增加参数:-XX:+CMSScavengeBeforeRemark
案例3:老年代充裕情况下,发生Full GC(CMS jdk1.7)
- 原因分析:由问题可以排除内存已满,和碎片过多。排查版本
- 解决;使用jdk 1.8
三.类加载与字节码技术
1.类文件结构
2.字节码指令
2.1 javap工具
Oracle 提供了 javap 工具来反编译 class 文件
[root@localhost ~]# javap -v 类名.class
2.2 图解方法执行流程
1)java代码
package cn.itcast.jvm.t3.bytecode;
/**
* 演示 字节码指令 和 操作数栈、常量池的关系
*/
public class Demo3_1 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
2)编译后的字节码文件
[root@localhost ~]# javap -v Demo3_1.class
Classfile /root/Demo3_1.class
Last modified Jul 7, 2019; size 665 bytes
MD5 checksum a2c29a22421e218d4924d31e6990cfc5
Compiled from "Demo3_1.java"
public class cn.itcast.jvm.t3.bytecode.Demo3_1
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#26 // java/lang/Object."<init>":()V
#2 = Class #27 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #28.#29 //
java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#6 = Class #32 // cn/itcast/jvm/t3/bytecode/Demo3_1
#7 = Class #33 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcn/itcast/jvm/t3/bytecode/Demo3_1;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 a
#20 = Utf8 I
#21 = Utf8 b
#22 = Utf8 c
#23 = Utf8 MethodParameters
#24 = Utf8 SourceFile
#25 = Utf8 Demo3_1.java
#26 = NameAndType #8:#9 // "<init>":()V
#27 = Utf8 java/lang/Short
#28 = Class #34 // java/lang/System
#29 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 cn/itcast/jvm/t3/bytecode/Demo3_1
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
public cn.itcast.jvm.t3.bytecode.Demo3_1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."
<init>":()V
4: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t3/bytecode/Demo3_1;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field
java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method
java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 8: 0
line 9: 3
3)常量池载入运行时常量池
4)方法字节码载入方法区
5)main 线程开始运行,分配栈帧内存
(stack=2,locals=4)
line 10: 6
line 11: 10
line 12: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
MethodParameters:
Name Flags
args
}
3)常量池载入运行时常量池
java代码被执行时,是由类加载器将main方法所在的类的class字节数据加载到内存中。
常量池中的数据会放到运行时常量池中。
注意:运行时常量池是方法区的一部分
比较小的数字(小于short的数字)并不会存放在常量池中,他们会和字节码指令存放在一起
4)方法字节码载入方法区
方法的字节码会放在方法区
5)main线程开始运行,分配栈帧内存
main方法运行之前会先分配栈帧内存
栈帧:局部变量表、操作数栈、动态链接、方法出口
(stack=2,locals=4):局部变量表是4,操作数栈是2
执行引擎开始执行字节码
bipush 10
- 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
- sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
- ldc 将一个 int 压入操作数栈(从常量池中拿取)
- ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
- 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池
istore 1
- 将操作数栈栈顶元素弹出,放入局部变量表的 slot 1 中
- 对应代码中的 a = 10
ldc #3
- 读取运行时常量池中 #3 ,即 32768 (超过 short 最大值范围的数会被放到运行时常量池中),将其加载到操作数栈中
- 注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的。
istore 2
将操作数栈中的元素弹出,放到局部变量表的 2 号位置
iload1 iload2
将局部变量表中 1 号位置和 2 号位置的元素放入操作数栈中。因为只能在操作数栈中执行运算操作
iadd
将操作数栈中的两个元素弹出栈并相加,结果在压入操作数栈中。
istore 3
将操作数栈中的元素弹出,放入局部变量表的3号位置。
getstatic #4
在运行时常量池中找到 #4 ,发现是一个对象,在堆内存中找到该对象,并将其引用放入操作数栈中
iload 3
将局部变量表中 3 号位置的元素压入操作数栈中。
invokevirtual #5
- 找到常量池 #5 项,定位到方法区 java/io/PrintStream.println:(I)V 方法(System.out.println(c);)
- 生成新的栈帧(分配 locals、stack等)
- 传递参数,执行新栈帧中的字节码
- 执行完毕,弹出栈帧
- 清除 main 操作数栈内容
return
完成 main 方法调用,弹出 main 栈帧,程序结束
2.3 练习分析a++
从字节码角度分析a++相关题目
package cn.itcast.jvm.t3.bytecode;
/**
* 从字节码角度分析 a++ 相关题目
*/
public class Demo3_2 {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--;
System.out.println(a);
System.out.println(b);
}
}
Code:
stack=2, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: iinc 1, 1
7: iinc 1, 1
10: iload_1
11: iadd
12: iload_1
13: iinc 1, -1
16: iadd
17: istore_2
分析:
- 注意 iinc 指令是直接在局部变量 slot 上进行运算
- a++ 和 ++a 的区别是先执行 iload 还是 先执行 iinc
步骤分析:
- bipush 10 将10 压入栈
- istore_1 放入1槽(slot)中
- iload_1 取出1对应的10
- iinc 1, 1 将1中的数据加1(在slot中进行)
- iinc 1, 1 将1中的数据加1(在slot中进行)
- iload_1 将1中的数据取出
- iadd 进行add操作
- iload_1 将1中的数据取出
- iinc 1, -1 将1中的数据减1(在slot中进行)
- iadd 进行add操作
- istore_2 将结果放进2槽中
图解:
- bipush 10 将10 压入栈
- istore_1 放入1槽(slot)中
- iload_1 取出1对应的10
- iinc 1, 1 将1中的数据加1(在slot中进行)
- iinc 1, 1 将1中的数据加1(在slot中进行)
- iload_1 将1中的数据取出
- iadd 进行add操作
- iload_1 将1中的数据取出
- iinc 1, -1 将1中的数据减1(在slot中进行)
- iadd 进行add操作
- istore_2 将结果放进2槽中
练习2
public static void main(String[] args) {
int i = 0;
int x = 0;
while (i < 10) {
x = x++;
i++;
}
System.out.println(x);
}
分析:
Code:
stack=2, locals=3, args_size=1 // 操作数栈分配2个空间,局部变量表分配 3 个空间
0: iconst_0 // 准备一个常数 0
1: istore_1 // 将常数 0 放入局部变量表的 1 号槽位 i = 0
2: iconst_0 // 准备一个常数 0
3: istore_2 // 将常数 0 放入局部变量的 2 号槽位 x = 0
4: iload_1 // 将局部变量表 1 号槽位的数放入操作数栈中
5: bipush 10 // 将数字 10 放入操作数栈中,此时操作数栈中有 2 个数
7: if_icmpge 21 // 比较操作数栈中的两个数,如果下面的数大于上面的数,就跳转到 21 。这里的比较是将两个数做减法。因为涉及运算操作,所以会将两个数弹出操作数栈来进行运算。运算结束后操作数栈为空
10: iload_2 // 将局部变量 2 号槽位的数放入操作数栈中,放入的值是 0
11: iinc 2, 1 // 将局部变量 2 号槽位的数加 1 ,自增后,槽位中的值为 1
14: istore_2 //将操作数栈中的数放入到局部变量表的 2 号槽位,2 号槽位的值又变为了0
15: iinc 1, 1 // 1 号槽位的值自增 1
18: goto 4 // 跳转到第4条指令
21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_2
25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
28: return
JVM层面:
- x++:是先将x放进操作数栈,然后在栈中的局部变量 slot 上进行++运算操作,最后=是将操作数栈上的数据赋值给x(放进局部变量 slot )
- ++x:是先在栈中的局部变量 slot 上进行++运算操作,然后将x放进操作数栈,最后=是将操作数栈上的数据赋值给x(放进局部变量 slot )
代码层面:
- x++:是先进行自增然后把x自增前的值赋值(0)给x
- ++x:是先进行自增然后把x自增后的值赋值(0)给x
2.4 构造方法
cinit()V:类的初始化
public class Code_12_CinitTest {
static int i = 10;
static {
i = 20;
}
static {
i = 30;
}
public static void main(String[] args) {
System.out.println(i); // 30
}
}
编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法 cinit()V :
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #3 // Field i:I
5: bipush 20
7: putstatic #3 // Field i:I
10: bipush 30
12: putstatic #3 // Field i:I
15: return
init()V:对象初始化
public class Code_13_InitTest {
private String a = "s1";
{
b = 20;
}
private int b = 10;
{
a = "s2";
}
public Code_13_InitTest(String a, int b) {
this.a = a;
this.b = b;
}
public static void main(String[] args) {
Code_13_InitTest d = new Code_13_InitTest("s3", 30);
System.out.println(d.a);
System.out.println(d.b);
}
}
编译器会按从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在后.
Code:
stack=2, locals=3, args_size=3
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String s1
7: putfield #3 // Field a:Ljava/lang/String;
10: aload_0
11: bipush 20
13: putfield #4 // Field b:I
16: aload_0
17: bipush 10
19: putfield #4 // Field b:I
22: aload_0
23: ldc #5 // String s2
25: putfield #3 // Field a:Ljava/lang/String;
// 原始构造方法在最后执行
28: aload_0
29: aload_1
30: putfield #3 // Field a:Ljava/lang/String;
33: aload_0
34: iload_2
35: putfield #4 // Field b:I
38: return
代码层面讲解:类加载
2.5 方法调用
public class Code_14_MethodTest {
public Code_14_MethodTest() {
}
private void test1() {
}
private final void test2() {
}
public void test3() {
}
public static void test4() {
}
public static void main(String[] args) {
Code_14_MethodTest obj = new Code_14_MethodTest();
obj.test1();
obj.test2();
obj.test3();
Code_14_MethodTest.test4();
}
}
不同方法在调用时,对应的虚拟机指令有所区别
- 私有、构造、被 final 修饰的方法,在调用时都使用 invokespecial 指令
- 普通成员方法在调用时,使用 invokevirtual 指令。因为编译期间无法确定该方法的内容,只有在运行期间才能确定(编译期间不能确定有没有调用重载或重写的方法)
- 静态方法在调用时使用 invokestatic 指令
Code:
stack=2, locals=2, args_size=1
0: new #2 //
3: dup // 复制一份对象地址压入操作数栈中,相当于两个,其中一个在invokespecial之后就会删除处理
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: invokestatic #7 // Method test4:()V
23: return
- new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入操作数栈
- dup 是赋值操作数栈栈顶的内容,本例即为【对象引用】,为什么需要两份引用呢,一个是要配合 invokespecial 调用该对象的构造方法 “init”: ()V (会消耗掉栈顶一个引用),另一个要 配合 astore_1 赋值给局部变量
- 终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定
- 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
静态方法被对象调用时候同样会执行类调用的字节码指令,所以建议直接使用类调用。
2.6多态的原理
因为普通成员方法需要在运行时才能确定具体的内容,所以虚拟机需要调用 invokevirtual 指令
在执行 invokevirtual 指令时,经历了以下几个步骤
- 先通过栈帧中对象的引用找到对象
- 分析对象头,找到对象实际的 Class
- Class 结构中有 vtable
- 查询 vtable 找到方法的具体地址
- 执行方法的字节码
多态的某个方法是子类方法还是父类方法,是直接体现在vtable中的,vtable会根据具体地址直接直接指向对性的字节码文件,也就直接找到了所在的对象。
2.7异常处理
try-catch
public class Code_15_TryCatchTest {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
}catch (Exception e) {
i = 20;
}
}
}
对应的字节码指令
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
//多出来一个异常表
Exception table:
from to target type
2 5 8 Class java/lang/Exception
- 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开(也就是检测 2~4 行)的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
- 8 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 2 号位置(为 e )
- 因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用
finally
public class Code_17_FinallyTest {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}
对应字节码
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1
// try块
2: bipush 10
4: istore_1
// try块执行完后,会执行finally
5: bipush 30
7: istore_1
8: goto 27
// catch块
11: astore_2 // 异常信息放入局部变量表的2号槽位
12: bipush 20
14: istore_1
// catch块执行完后,会执行finally
15: bipush 30
17: istore_1
18: goto 27
// 出现异常,但未被 Exception 捕获,会抛出其他异常,这时也需要执行 finally 块中的代码
21: astore_3
22: bipush 30
24: istore_1
25: aload_3
26: athrow // 抛出异常
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any
11 15 21 any
可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流程
注意:虽然从字节码指令看来,每个块中都有 finally 块,但是 finally 块中的代码只会被执行一次
finally中的return
public class Code_18_FinallyReturnTest {
public static void main(String[] args) {
int i = Code_18_FinallyReturnTest.test();
// 结果为 20
System.out.println(i);
}
public static int test() {
int i;
try {
i = 10;
return i;
} finally {
i = 20;
return i;
}
}
}
对应字节码
Code:
stack=1, locals=3, args_size=0
0: bipush 10
2: istore_0
3: iload_0
4: istore_1 // 暂存返回值
5: bipush 20
7: istore_0
8: iload_0
9: ireturn // ireturn 会返回操作数栈顶的整型值 20
// 如果出现异常,还是会执行finally 块中的内容,没有抛出异常
10: astore_2
11: bipush 20
13: istore_0
14: iload_0
15: ireturn // 这里没有 athrow 了,也就是如果在 finally 块中如果有返回操作的话,且 try 块中出现异常,会吞掉异常!
Exception table:
from to target type
0 5 10 any
- 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以finally的为准
- 至于字节码中第 2 行,似乎没啥用,且留个伏笔,看下个例子
- 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会吞掉异常
- 所以不要在finally中进行返回操作
被吞掉的异常
public static int test() {
int i;
try {
i = 10;
// 这里应该会抛出异常
i = i/0;
return i;
} finally {
i = 20;
return i;
}
}
会发现打印结果为 20 ,并未抛出异常
finally 不带 return
public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
对应字节码
Code:
stack=1, locals=3, args_size=0
0: bipush 10
2: istore_0 // 赋值给i 10
3: iload_0 // 加载到操作数栈顶
4: istore_1 // 加载到局部变量表的1号位置
5: bipush 20
7: istore_0 // 赋值给i 20
8: iload_1 // 加载局部变量表1号位置的数10到操作数栈
9: ireturn // 返回操作数栈顶元素 10
10: astore_2
11: bipush 20
13: istore_0
14: aload_2 // 加载异常
15: athrow // 抛出异常
Exception table:
from to target type
3 5 10 any
返回的是10
2.8Synchronized
public class Code_19_SyncTest {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
}
对应字节码
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object
3: dup // 复制一份栈顶,然后压入栈中。用于函数消耗
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1 // 将栈顶的对象地址方法 局部变量表中 1 中
8: aload_1 // 加载到操作数栈
9: dup // 复制一份,放到操作数栈,用于加锁时消耗
10: astore_2 // 将操作数栈顶元素弹出,暂存到局部变量表的 2 号槽位。这时操作数栈中有一份对象的引用
11: monitorenter // 加锁
12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
15: ldc #4 // String ok
17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_2 // 加载对象到栈顶
21: monitorexit // 释放锁
22: goto 30
// 异常情况的解决方案 释放锁!
25: astore_3
26: aload_2
27: monitorexit
28: aload_3
29: athrow
30: return
// 异常表!
Exception table:
from to target type
12 22 25 any
25 28 25 any
3. 编译期处理(语法糖)
待学习。。。。。。
4. 类加载阶段
4.1 加载
- 将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法
- 如果这个类还有父类没有加载,先加载父类
- 加载和链接可能是交替运行的
- instanceKlass保存在方法区。JDK8之后,方法区位于元空间中,而元空间由位于本地内存中
- _java_mirror是保存在堆内存中
- instanceKlass和*.class(java镜像)互相保存了对方的地址
- 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceClass,从而获取类的各种信息
4.2 链接
4.2.1 验证
验证类是否复核jvm规范,安全性检查
4.2.2 准备
为static分配空间,设默认值
- static变量在JDK7之前是存储在instanceKlass末尾。但在JDK7之后就存储在_java_mirror末尾了
- static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果static变量是final的基本类型,以及字符串常量,那么编译期间就能确定了,赋值就会在准备阶段完成
- 如果static变量是final的,但是属于引用类型(对象),那么赋值就会在初始化阶段完成
4.2.3 解析
将常量池中的符号引用解析为直接引用
- 未解析时常量池中看到的对象仅是符号,未真正的存在于内存中,解析之前不会知道这些符号对应的具体值
public class Demo1 {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ClassLoader loader = Demo1.class.getClassLoader();
//只加载不解析
Class<?> c = loader.loadClass("com.nyima.JVM.day8.C");
//用于阻塞主线程
System.in.read();
}
}
class C {
D d = new D();
}
class D {
}
loader不会触发解析,也不会触发初始化。
使用HSDB查看
- 此时只加载了类C
查看类C的常量池,可以看到类D未被解析,只是存在于常量池中的符号
- 解析以后(new C),会将常量池中的符号引用解析为直接引用
- 可以看到,此时已加载并解析了类C和类D
附:HSDB的使用
- 先获得要查看的进程ID
jps
Copy
- 打开HSDB
java -cp F:\JAVA\JDK8.0\lib\sa-jdi.jar sun.jvm.hotspot.HSDB
Copy
- 运行时可能会报错,是因为缺少一个.dll的文件,我们在JDK的安装目录中找到该文件,复制到缺失的文件下即可
- 定位需要的进程
4.3 初始化
初始化阶段就是执行类构造器clinit()方法的过程,虚拟机会保证这个类的构造方法的线程安全
- clinit方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的
注意
编译器手机的顺序是由语句在源文件中出现的顺序决定的,惊天语句块中只能访问到定义的静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以复制,但不能访问
类的初始化是懒惰的,以下情况不会初始化
- 访问类的static final 静态变量(基本类型和字符串)
- 类.class不会触发初始化
- 创建该类对象的数组
- 类加载器的.loadClass方法
- Class.forNamed的参数2为false时
以下情况会初始化
- main方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化会先初始化父类
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new一个对象
验证类是否被初始化,可以看该类的静态代码块是否被执行
5. 类加载器
java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”的动作放到了java虚拟机外部实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为类加载器
5.1 类与类加载器
类加载器虽然只用于实现类的加载过程,但是它在java程序中起到的作用却远超类加载阶段
对于任意一个类,都必须由加载他的类加载器和这个类本身一起共同确立其在java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类名称空间。通俗讲就是:比较两个类是否相等,只有这两个类是由同一个类加载器加载的前提下才有意义。否则,即使两个类来源于同一个class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类也必定不相等。
名称 | 加载的类 | 说明 |
---|---|---|
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader(应用程序类加载器) | classpath | 上级为Extension |
自定义类加载器 | 自定义 | 上级为Application |
启动类加载器
可以通过在控制台输入指令,使得类被启动类加载器加载
拓展类加载器
如果classpath和JAVA_HOME/jre/lib/ext 下有同类名,加载时会使用拓展类加载器加载。当应用程序类加载器会发现拓展类加载已将该同名类加载过了,则不会再次加载。
5.2 双亲委派模式
双亲委派模式,即调用类加载器ClassLoader的loadClass方法时,查找类的规则
loadClass源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先查找该类是否已经被该类加载器加载过了
Class<?> c = findLoadedClass(name);
//如果没有被加载过
if (c == null) {
long t0 = System.nanoTime();
try {
//看是否被它的上级加载器加载过了 Extension的上级是Bootstarp,但它显示为null
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//看是否被启动类加载器加载过
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
//捕获异常,但不做任何处理
}
if (c == null) {
//如果还是没有找到,先让拓展类加载器调用findClass方法去找到该类,如果还是没找到,就抛出异常
//然后让应用类加载器去找classpath下找该类
long t1 = System.nanoTime();
c = findClass(name);
// 记录时间
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
步骤:
- 首先该类是否已经被类加载器加载过了
- 如果没有被加载过
- 看是否被它的上级加载器加载过 Extension的上级是Bootstarp。但它显示为null
- 看是否被启动类加载器加载过
- 有try。。。catch捕获异常但是不作处理
- 如果还是没有找到,先让拓展类加载器调用findClass方法找到该类,如果没有找到,就抛出异常
- 然后让应用类加载器去classpath下找该类
5.3 自定义类加载器
使用场景:
- 想加载非classpath,随意路径中的类文件
- 通过接口实现,希望解耦时,常用在框架设计
- 这些类希望得到隔离,不同应用的同名类都可以加载,不冲突,常见于tomcat容器
步骤:
- 继承classloder父类
- 要遵从双亲委派机制,重写findClass方法。不重写loadClass方法就不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的defineClass方法来加载类
- 使用者调用该类加载器的loadClass方法
class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throw ClassNotFoundException {
String path = "e:\\myclasspath\\"+name+".class";
try{
ByteArrayOutputStream os = new ByteArrayOutputStream();
Files.copy(Path.get(path), os);
//得到字节组
byte[] bytes = os.toByteArray();
//byte[] -> *.class
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e){
e.printStackTrace();
throw new ClassNotFoundException("类文件没有找到",e);
}
}
}
public class Test {
pubulic static void main(String[] args) throws Exception {
MyClassloader classLoader = new MyClassLoader();
Class<?> c1 = classLoader.loadClass("test1");
Class<?> c2 = classLoader.loadClass("test1");
System.out.println(c1==c2);
MyClassloader classLoader2 = new MyClassLoader();
Class<?> c3 = classLoader2.loadClass("test1");
System.out.println(c1==c3);
}
}
ture
false
6. 运行期优化
6.1 分层编译
JVM将执行状态分为5个层次
- 0层:解释执行,用解释器将字节码翻译成为机器码
- 1层:使用c1即时编译器编译执行(不带profiling)
- 2层:使用c1即时编译器编译执行(带基本的profiling)
- 3层:使用c1即时编译器编译执行(带完全的profiling)
- 4层:使用c2即时编译器编译执行
普通代码的执行都是0层执行,但是部分代码被多次调用之后会自动上升为1层。
c2和c1比较,c2即时编译器优化更彻底。
profiling是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的回边次数】等
即时编译器和解释器的区别
解释器:
- - 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- - 将字节码解释为针对所有平台都能通用的机器码
即时编译器:
- - 将一些字节码编译成为机器码,并存入Code Cache ,下次遇到相同的代码,直接执行,无需编译
- - 根据平台类型,生成平台特定的机器码
对于大部分的不常用的代码,通常是直接采用解释执行的方式运行;对于仅占据小部分的热点代码,我们则可以将其编译成成机器码,以达到理想的运行速度。执行效率上简单比较是 interpreter<c1<c2 ,总的目标是发现热点代码(hotsport名称的由来)并优化这些热点代码
逃逸分析
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术
逃逸分析的 JVM 参数如下:
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
6.2 方法内联
JVM内建有许多运行时优化。首先短方法更利于JVM推断。流程更明显,作用域更短,副作用也更明显。如果是长方法JVM可能直接就跪了。
第二个原因则更重要:方法内联
如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身,如:
private int add4(int x1, int x2, int x3, int x4) {
//这里调用了add2方法
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}
private int add4(int x1, int x2, int x3, int x4) {
//被替换为了方法本身
return x1 + x2 + x3 + x4;
}
6.3 反射优化
7. java 内存模型 (JMM)
java内存模型和java内存结构是不同的,java内存模型是定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、原子性的规则和保障
7.1 原子性
7.1.1 问题解析
提出问题:两个线程对初始值是0的静态变量一个做自增,一个做自减,各5000次,结果是0吗?
public class Demo1 {
static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
i--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
答案是不一定,结果可能是正数 、零 、负数。因为java中对静态变量的自增,自减并不是原子操作。
其中 i++产生的JVM字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i
i--产生的字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i
而java的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换。
所以在多线程下就会出现字节码交错执行
正常执行顺序:
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
getstatic i // 线程1-获取静态变量i的值 线程内i=1
iconst_1 // 线程1-准备常量1
isub // 线程1-自减 线程内i=0
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=0
出现负数情况:
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
出现正数:
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
7.1.2 解决办法
synchronized(同步关键字)
语法:
synchronized( 对象 ) {
要作为原子操作代码
}
解决问题:
public class Demo1 {
static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
synchronized (obj) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 50000; j++) {
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);//输出为0
}
}
从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter 和monitorexit指令实现的。 monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
7.2 可见性
7.2.1 退不出的循环
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}
当主线程对静态变量run修改后,t 线程也没有停止
原因:
- 初始状态,t 线程刚开始时从内存读取run到工作区内存
- 后来发现t 线程要频繁从主内存中读取run的值,JIT编译期就会将run的值缓存至自己工作内存中的告诉缓存中,减少对主内存中run的访问,提高效率
- 再后来,main线程修改了run的值,并同步到了主内存中,但是t 线程是从自己的工作内存中的高速缓存中读取的run变量,所以此次修改没有造成t 线程的停止。
7.2.2 解决办法
增加易变关键字 volatile [ˈvɑːlətl]
volatile可以用来修饰成员变量和静态变量,它可以避免线程从自己的工作缓存中查找变量值,必须到主内存中获取它的值,线程操作volatile修饰的变量都是直接从主内存中获取值的,保证了共享变量的可见性,但是并不能保证原子性。
public class Demo1 {
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}
}
注意:
-
synchronized
语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但 缺点是synchronized
是属于重量级操作,性能相对更低 - 如果在前面示例的死循环中加入
System.out.println()
会发现即使不加 volatile 修饰符,线程 t 也 能正确知道 run 变量的修改了
是因为println方法
public void println(int x) {
synchronized (this) {
print(x);
newLine();
}
}
可以看出加了synchronized
,保证了每次run
变量都会从主存中获取
7.3 有序性
7.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;
}
其实这里会有三种情况出现:
- 情况一:线程1先执行,这是ready是false,就会走到else中结果是1(r是返回的值)
- 情况二:线程2先执行但是在还没有执行到ready=true时候,线程1开始执行,这时同上,结果是1
- 情况三:线程3线程2先执行,线程1在执行,这时,进入到if分支,结果是4(已经执行了num=2)
其实还有可能出现为0的情况!
- 情况四:先执行线程2,但是并没有先对num赋值,而是执行的ready=true。然后就切换到线程1,进入if分支,相加为0.然后切换到线程2,执行num=2.
这种现象就是指令重排。(出现概率小)。
7.3.2 解决办法
volitile修饰的变量,可以禁用指令重排
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;//可以禁用指令重排
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
7.3.3 有序性的理解
同一线程内,jvm会在不影响正确性的前提下,可以调整语句的执行顺序,例如:
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;
可以看到先执行i,还是先执行j对结果没有影响,所以,上面代码真正执行时可以是
i = ...; // 较为耗时的操作
j = ...;
也可以是
j = ...;
i = ...; // 较为耗时的操作
这种特性称为指令重排,多线程下指令重排会影响正确性,例如著名的 double-checked locking
模式实现单例
可能会发生指令重排
public class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
//实例没创建,才会进入内部的 synchronized 代码块
if (INSTANCE == null) {
//可能第一个线程在synchronized 代码块还没创建完对象时,第二个线程已经到了这一步,所以里面还需要加上判断
synchronized (Singleton.class) {
//也许有其他线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
上面的代码看似已经很完美了,但是在多线程环境下还是会有指令重排问题!
INSTANCE = new Singleton()
对应的字节码为:
0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field INSTANCE:Lcn/itcast/jvm/t4/Singleton;
其中4 7 两步顺序不是固定的,也许 jvm 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间顺序执行:
时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接
返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将 是一个未初始化完毕的单例
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才 会真正有效
7.4 happens-before
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
- 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见(线程1对x上锁m,改变值,解锁后,上锁m的线程2对x可见)
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start()
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
- 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用
t1.isAlive()
或t1.join()
等待它结束)
static int x;
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);//0
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
}
-
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
-
具有传递性,如果
x hb-> y
并且y hb-> z
那么有x hb-> z
以上变量都是指共享变量即成员变量或静态资源变量
8. CAS与原子类
8.1 CAS
CAS即compare and swap,它体现的是一种乐观锁的思想
比如多个线程要对一个共享的整型变量执行+1操作:
// 需要不断尝试
while(true) {
int 旧值 = 共享变量 ; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if( compareAndSwap ( 旧值, 结果 )) {
// 成功,退出循环
}
//不一样,继续循环尝试
}
获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈。多核cpu的场景下。
- 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
CSA底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令,下面是直接使用Unsafe对象进行线程安全保护的一个例子:
public class TestCAS {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t.start();
t.join();
System.out.println(dc.getData());
}
}
class DataContainer {
private volatile int data;
static final Unsafe unsafe;
static final long DATA_OFFSET;
static {
try {
// Unsafe 对象不能直接调用,只能通过反射获得
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
}
try {
// data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
DATA_OFFSET =
unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new Error(e);
}
}
public void increase() {
int oldValue;
while (true) {
// 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
oldValue = data;
// cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue +
1)) {
return;
}
}
}
public void decrease() {
int oldValue;
while (true) {
oldValue = data;
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue -
1)) {
return;
}
}
}
public int getData() {
return data;
}
}
8.2 乐观锁和悲观锁
- CAS是基于乐观锁的思想:不怕别的线程来修改共享变量,即使做了修改,就会自己再次重试。
- Synchrnized是基于悲观锁思想:默认为线程都会修改共享变量,所以只有放开锁之后才会被别的线程修改。
8.3 原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以供线程安全的操作,例如:AtomicIteger、AtomicBoolean等,两个类的底层都是采用了CAS技术和volitile来实现的。可以使用Atomiclnteger改写之前的例子:
public class TestCAS {
//创建原子整数对象
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++
}
});
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);//0
}
}
9. synchronized 优化
java HotStop虚拟机中,每个对象都有对象头(包括class指针和mark word)。mark word 平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就会根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID等内容
9.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(没有竞争),那么可以使用轻量级锁来优化。这就好比:
学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明有竞争,继续上他的课。 如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来,假设有两个方法同步块,利用同一个对象加锁
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
9.2锁膨胀
如果尝试加轻量级锁的过程中,CAS操作无法成功,这种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
9.3重量锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候锁线程已经退出了同步块,释放了锁),这时线程就可以避免阻塞。
在java6之后自旋锁是适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性高,就会自旋多次;反之,就少自旋甚至不自旋,总之,比较智能。
9.4偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己就表示没有竞争,不用重新CAS
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的hashC也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重新偏向会重置对象的Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用 -xx
:-UseBiasedLocking
禁用偏向锁
9.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)读写分离
如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用
-XX:-UseBiasedLocking
禁用偏向锁