文章目录
- 1. JVM内存分配
- 程序计数器
- 虚拟机栈
- 栈帧都有哪些内容
- 栈内存溢出
- 线程运行诊断
- 演示1(cpu占用过多)
- 演示2(死锁)
- 本地方法栈
- 堆
- 堆内存诊断
- jmp诊断堆内存
- jconsole诊断堆内存
- jvisualvm诊断堆内存
- 方法区
- 直接内存
- java操作磁盘文件
- NIO操作磁盘文件
- 内存溢出
- 2. 垃圾回收
- 对象回收算法
- 引用计数法
- 可达性分析法
- 根对象种类
- 强软弱虚
- 特点
- 垃圾回收算法
- 分代垃圾回收
- 垃圾回收器
- 串行
- 吞吐量优先
- 响应时间优先
- 并发和并行垃圾回收区别
- CMS垃圾回收器
- 参数设置:
- 问题
- G1 垃圾回收器
- 特点:
- 回收过程
- 使用场景
- 参数设置
- Full GC
- 调优
- 调优准则
- 尽量避免发生GC
- 新生代调优
- 老年代调优
- GC场景
- **案例1 FullGC和MinorGC频繁**
- **案例2 请求高峰期发生FullGC,单次暂停时间特别长(CMS)**
- **案例3 老年代充裕情况下,发生FullGC()**
- 优化:JDK 8u20字符串去重
- 优化:JDK 8u40并发标记类卸载
- 优化:JDK 8u60回收巨型对象
- 优化:JDK 9并发标记起始时间的调整
- 优化:JDK 9更高效的回收
- 3. 编译器处理
- 语法糖
- 4. 类加载
- 类加载
- 类加载器
- 分类
- 双亲委派模型:
- 如何打破双亲委派机制?
- 自定义类加载器使用场景
- 5. 内存模型
- 原子性保障
- 可见性
- 有序性
- 双检索
- volatile实现原理:
- CAS与原子类
- 进阶知识点
- 1. 逃逸分析
- 2. 为什么对象寿命大于15进入老年代
- 3. 线程内的OOM是否会影响主线程的运行?
- 4. JIT优化
1. JVM内存分配
程序计数器
解释:
编写好的Java程序先进行编译,编译成二进制的字节码文件。字节码解释器依次来读取这些文件内容。
在读取的过程中,如遇到程序控制等相关的执行语句的时候,需要跳跃读取字节码内容,读完继续之前的位置进行读取,此时程序计数器用于记录之前读取的文件的位置。
在多线程的情况下,如果遇到线程切换,需要用程序计数器记录之前的线程执行的位置,等线程切换完毕后,继续之前之前线程执行的位置。
作用:
- 记住下一条jvm指令的执行地址
- 多线程切换时,记录当前线程的执行位置。
特点:
- 线程私有,随线程的消亡而消亡
- 不会存在内存溢出
注意:程序计数器是CPU中寄存器实现的。
虚拟机栈
作用:
每开辟一条线程,都会创建一个虚拟机栈,用于当前线程运行所需的内存空间。当我们调用方法的时候,此时该方法就会进行压栈操作,作为栈帧,每个方法都有一定的内存空间,用于存储(局部变量表,操作数栈,动态链接,返回地址等)当方法执行完毕后,就会执行出栈操作。
特点:
- 线程私有,生命周期和线程相同
- 会出现
StackOverFlowError
和OutOfMemoryError
两种错误
相关问题:
-
垃圾回收是否涉及到栈内存
不涉及。栈内存是方法调用是分配的,在方法结束调用后,就将栈帧弹出栈了,释放了内存。
-
栈内存分配越大越好吗
并不是。系统的物理内存是一定的,栈空间越大,会导致线程数越少。
栈空间越大,也并不会让程序更快,只是有更大的栈空间,能让你做更多次的递归调用。
-
方法内的局部变量是否线程安全
- 判断是否安全,即看这些变量对于多个线程是共享的还是私有的。
- 如果方法内局部变量没有逃离方法的作用范围(无return或传参),它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用方法(return或传参),需要考虑线程安全
栈帧都有哪些内容
局部变量表,操作数栈,动态链接,返回地址
**局部变量表:**方法参数和方法内定义的局部变量
**动态链接:**指向运行时常量池的方法引用
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里,程序运行时将其加载进方法区的运行时常量池中。
描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
返回地址:
当一个方法开始执行时,可能有两种方式退出该方法:
- 正常完成出口:如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。
- 异常完成出口:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。
在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。
**操作数栈:**操作数栈就是JVM执行引擎的一个工作区,当一个方法被调用的时候,一个新的栈帧也会随之被创建出来,但这个时候栈帧中的操作数栈却是空的,只有方法在执行的过程中,才会有各种各样的字节码指令往操作数栈中执行入栈和出栈操作。比如在一个方法内部需要执行一个简单的加法运算时,首先需要从操作数栈中将需要执行运算的两个数值出栈,待运算执行完成后,再将运算结果入栈。
代码8-2 执行加法运算的字节码指令
- public void testAddOperation();
- Code:
- 0: bipush 15
- 2: istore_1
- 3: bipush 8
- 5: istore_2
- 6: iload_1
- 7: iload_2
- 8: iadd
- 9: istore_3
- 10: return
在上述字节码指令示例中,首先会由“bipush”指令将数值15从byte类型转换为int类型后压入操作数栈的栈顶(对于byte、short和char类型的值在入栈之前,会被转换为int类型),当成功入栈之后,“istore_1”指令便会负责将栈顶元素出栈并存储在局部变量表中访问索引为1的Slot上。接下来再次执行“bipush”指令将数值8压入栈顶后,通过“istore_2”指令将栈顶元素出栈并存储在局部变量表中访问索引为2的Slot上。“iload_1”和“iload_2”指令会负责将局部变量表中访问索引为1和2的Slot上的数值15和8重新压入操作数栈的栈顶,紧接着“iadd”指令便会将这2个数值出栈执行加法运算后再将运算结果重新压入栈顶,“istore_3”指令会将运算结果出栈并存储在局部变量表中访问索引为3的Slot上。最后“return”指令的作用就是方法执行完成之后的返回操作。在操作数栈中,一项运算通常由多个子运算(subcomputation)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。
在此大家需要注意,在操作数栈中的数据必须进行正确的操作。比如不能在入栈2个int类型的数值后,却把它们当做long类型的数值去操作,或者入栈2个double类型的数值后,使用iadd指令对它们执行加法运算等情况出现。
操作数栈 - shizhiyi - 博客园 (cnblogs.com)
栈内存溢出
栈空间调整参数
-Xss空间大小
-Xss8M
- 栈帧过多导致栈内存溢出
- 当程序递归调用次数太多时,会超出栈的空间,导致栈内存溢出。
- 栈帧过大导致栈内存溢出
- 变量过大(一般不会出现)
- 方法携带的参数等占用内存太多,导致栈帧过大,使栈内存溢出。
线程运行诊断
案例1:cpu占用过高【全网独家】解读大厂高并发设计20问,收藏学习进大厂!_哔哩哔哩_bilibili
定位
- 用top定位哪个进程对cpu的占用过高
- top
- 用ps命令进一步定位是哪个线程引起的cpu占用过高
- ps H -eo pid,tid,%cpu | grep 32655
- jstack根据线程id找到有问题的线程,进一步定位到问题代码的源码行数。
- jstack 进程id
案例2:程序运行很长时间没有结果
演示1(cpu占用过多)
# 使用top命令查看当前cup运行情况
top
# 使用ps查看线程的运行情况
# -eo 后的参数是想要查看的参数信息,pid进程号,tid线程号,%cpu cpu占用率
ps H -eo pid,tid,%cpu | grep 32655
32665线程有大问题。
# 输出进程内的所有信息,线程号用16进制表示的
# 32665线程换算成16进制为7f99
jstack 32655
演示2(死锁)
在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。
当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁。
jstack 32275
package cn.itcast.jvm.t1.stack;
/**
* 演示线程死锁
*/
class A{};
class B{};
public class Demo1_3 {
static A a = new A();
static B b = new B();
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
synchronized (a) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println("我获得了 a 和 b");
}
}
}).start();
Thread.sleep(1000);
new Thread(()->{
synchronized (b) {
synchronized (a) {
System.out.println("我获得了 a 和 b");
}
}
}).start();
}
}
本地方法栈
native修饰的方法。
java类并不是所有的方法都是java代码编写的,有些底层的方法就是通过c/c++实现的。而java可以调用这些底层方法来完成一些功能。在java调用这些底层方法时,就是运行在本地方法栈中。
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间。
特点:
- 线程私有,随线程的消亡而消亡
- 会出现
StackOverFlowError
和OutOfMemoryError
两种错误
堆
new 关键字创建的对象会占用堆内存。
Java中的对象不一定是在堆上分配的,因为JVM通过逃逸分析,能够分析出一个新对象的使用范围,并以此确定是否要将这个对象分配到堆上。
【性能优化】面试官:Java中的对象都是在堆上分配的吗? - 冰河团队 - 博客园 (cnblogs.com)
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 会产生OutOfMemoryError错误。
- 有垃圾回收机制
堆空间调整参数
堆内存分配:
JVM初始分配的内存由-Xms指定,默认是物理内存的1/64;JVM最大分配的内存由-Xmx指定,默认是物理内存的1/4。默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制。因此服务器一般设置-Xms、-Xmx相等以避免在每次GC后调整堆的大小。
非堆内存分配(方法区):
1.8之前:
-XX:PermSize:表示非堆区初始内存分配大小,其缩写为permanent size(持久化内存) 默认是物理内存的1/64
-XX:MaxPermSize:表示对非堆区分配的内存的最大上限。默认是物理内存的1/4。
1.8之后
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
查看虚拟机内存:XshowSettings:vm
VM settings:
Max. Heap Size (Estimated): 3.53G
Ergonomics Machine Class: client
Using VM: Java HotSpot(TM) 64-Bit Server VM
案例代码
package cn.itcast.jvm.t1.heap;
import java.util.ArrayList;
import java.util.List;
/**
* 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space
* -Xmx8m
*/
public class Demo1_5 {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "hello";
while (true) {
list.add(a); // hello, hellohello, hellohellohellohello ...
a = a + a; // hellohellohellohello
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}
在实际生产中,对于堆内存溢出问题,可能并不是那么容易检测出来。因为堆内存空间比较大,在运行时,一时间还不会使其溢出。
所以为了使堆内存问题尽早暴露出来,可以在测试时,将堆内存空间调整小一些。
堆内存诊断
- jps工具
- 查看当前系统中有哪些java进程
- jmap工具
- 查看某一时刻堆内存占用情况
- jmap -heap -pid 进程id
- jconsole工具
- 图形界面的,多功能的监测工具,可以连续监测
- 堆内存调整指令参数
- -Xmx容量大小
jmp诊断堆内存
案例代码
package cn.itcast.jvm.t1.heap;
/**
* 演示堆内存
*/
public class Demo1_4 {
public static void main(String[] args) throws InterruptedException {
System.out.println("1...");
Thread.sleep(30000);
byte[] array = new byte[1024 * 1024 * 10]; // 10 Mb
System.out.println("2...");
Thread.sleep(20000);
array = null;
System.gc();
System.out.println("3...");
Thread.sleep(1000000L);
}
}
- Thread.sleep 是为了留有时间间隔执行命令,监控进程状态
- 程序打印 1… 后,执行jps查看该进程的进程号
- jmap -heap 进程id,查看这一时刻进程堆空间使用情况
- 程序打印 2… 后,再次执行 jmap 指令查看内存情况
- 程序打印 3… 后,再次执行 jmap 指令查看内存情况
程序运行后
jps
1580为该进程的pid,调用命令
jmap -heap 1580
具体的堆内存占用在Heap Usage
在程序打印了 2… 后,再次
jmap -heap 1580
按理说应该增加10M,此处有些疑惑
在打印了 3… 之后,代表着已经被垃圾回收了
jmap -heap 1580
jconsole诊断堆内存
控制台输入:jconsole
但是在jconsole里面可以看出,在给array初始化后,堆内存使用量增加了10M,在垃圾回收后,堆内存使用量又迅速下降。
jvisualvm诊断堆内存
控制台输入:jvisualvm
问题:程序执行过GC之后,内存占用空间还是居高不下。比如没GC之前是250,GC之后230的现象。
jvisualvm是功能更加强大的图形化jvm管理软件。可以进行堆转储,拿到进程某一时刻的快照dump进行分析。
案例代码:
package cn.itcast.jvm.t1.heap;
import java.util.ArrayList;
import java.util.List;
/**
* 演示查看对象个数 堆转储 dump
*/
public class Demo1_13 {
public static void main(String[] args) throws InterruptedException {
List<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++) {
students.add(new Student());
// Student student = new Student();
}
Thread.sleep(1000000000L);
}
}
class Student {
private byte[] big = new byte[1024*1024];
}
经过测试,在执行了垃圾回收后,堆内存占用还是居高不下。
于是点击 堆dump 拿取快照,分析详情
点击查看
由源代码可知,确实是Student类的原因。
class Student {
private byte[] big = new byte[1024 * 1024 * 10];
}
student数组一直在循环引用,没有被垃圾回收。
方法区
方法区内部结构
方法区存储的内容:
- 类结构相关的内容:类型信息(版本,成员变量,方法,构造器,接口),字面量(字符串,常量,静态变量)以及相关的代码。
- 运行时常量池
- 常量池表中的相关内容,在类加载后会存放在运行时常量池中。
- 受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
-
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
-
运行时常量池,常量池是 *.class 文件中的,当该类被加载(运行的时候),它的常量池信息就会放入运行时常量池(类的信息放入内存中),并把里面的符号地址变为真实地址。
- 运行时常量池相对常量池来说,具有动态性。java并不要求所有的常量只有在编译器产生,即使在运行期,也可产生常量,放入运行时常量池。如String的intern()。
特点:
- 线程共享
- 如果方法区申请的内存空间不足,也会抛出OOM 异常。
变化历程:
方法区是一种规范,永久代和元空间都只是它的实现。
jdk1.6时,方法区使用的是堆的一部分。(待确定)
jdk1.7时,将字符串常量池和静态变量移出。(字符串常量池,静态变量移动到了堆)
JDK1.8时,方法区不直接占用JVM虚拟机内存,而是占用操作系统内存。(除了字符串常量池和静态变量,其他的还在方法区)。
1.8之前
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
1.8
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
问题:为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
常量池表:
// 二进制字节码(类基本信息,常量池,类方法定义,包含了虚拟机指令)
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
- 先进入到java源文件的目录
cd 目标目录
- 将HelloWorld.java 编译成 HelloWorld.class
javac HelloWorld.java
- 反编译HelloWorld.class
javap -v HelloWorld.class
结果如下
直接内存
不是虚拟机的内存,是系统内存。Direct Memory
- 常见于NIO操作时,用于数据缓存区(ByteBuffer)
- 分配回收成本过高,但读写性能高
- 不受JVM内存回收管理
- 会产生OutOfMemoryError 异常
直接内存调整参数
可以通过 -XX:MaxDirectMemorySize 参数来设置最大可用直接内存,如果启动时未设置则默认为最大堆内存大小,即与 -Xmx 相同。即假如最大堆内存为1G,则默认直接内存也为1G,那么 JVM 最大需要的内存大小为2G多一些。当直接内存达到最大限制时就会触发GC,如果回收失败则会引起OutOfMemoryError。
-XX:MaxDirectMemorySize 设置最大可用直接内存
java操作磁盘文件
当java读取磁盘文件时,会从用户态切换到内核态,才能去操作系统内存。读取时,系统内存先开辟一块缓存空间,磁盘文件分块读取。然后java虚拟机内存再开辟缓存空间new Byte[]来读取系统内存的文件。由于有从系统内存读取到java虚拟机的内存,所以效率较低。
NIO操作磁盘文件
读取磁盘文件时,会有一块直接内存,java虚拟机和视同内存都能访问使用,所以效率更高。
内存溢出
每次开辟100MB的直接内存,并且添加到集合中,不进行释放。
allocateDirect:开辟一块直接内存空间。
public class demo1_24 {
static int _100MB = 1024 * 1024 * 100;
public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100MB);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
}
}
2. 垃圾回收
对象回收算法
对象回收算法:用途判断对象是否可以被回收。
分类:1.引用计数法,2.可达性分析算法
引用计数法
某对象被引用一次,则引用次数加1,当引用次数为0时,没有被引用,则被回收。
问题:循环引用,导致永久不会回收
Java虚拟机没有采用此种算法。
可达性分析法
垃圾回收之前,垃圾回收器采用可达性分析法先扫描堆中的所有存活对象并确定根对象,判断其他对象是否被根对象引用,如果被根对象直接或间接引用,则不会被回收,否则对象被垃圾回收。
根对象种类
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- ·在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- ·在方法区中常量引用的对象,譬如字符串常量池( String Table)里的引用。
- ·在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- ·所有被同步锁(synchronized关键字)持有的对象。
- ·反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
强软弱虚
- 强引用:在程序中普遍存在的引用赋值,如: Object a = new Object()任何情况下,只要强引用关系还在,对象就不会被回收。
- 软引用:一些还有用,但非必须的对象。
- 弱引用:非必须对象,比软引用更弱一些
- 虚引用:无法通过虚引用获取一个对象实例,设置虚引用的目的只是为了对象在被垃圾回收的时候,收到一个系统通知。
- 终结器应用
特点
注意:此处的垃圾回收指的是FullGC
- 强引用:
- 只有所有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。
- 软引用(SoftReference)
- 仅有软引用引用该对象时,在垃圾回收后(Full),内存仍不足时会再次触发垃圾回收(Full),回收软引用对象,如何还不足,抛出内存溢出
- 可以配合引用队列来释放软引用自身
- 弱引用(WeakReference)
- 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。(普通垃圾回收只会回收部分弱引用,只要有空间使用即可停止回收,FUll则回收所有弱引用)
- 可以配合引用队列来释放弱引用自身。
- 虚引用(PhantomReference)
- 必须配合引用队列使用,主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存。
- 终结器引用(FinalReference)
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象。
注意:软弱引用不一定配置引用队列,虚引用和终结器引用必须配合引用队列
详情参考:[mystudy/Java/G-虚拟机/Java虚拟机/2. JVM垃圾回收.md · Zhang-HaoQi/Knowledge - 码云 - 开源中国 (gitee.com)](https://gitee.com/zhang-haoqi/knowledge/blob/develop/mystudy/Java/G-虚拟机/Java虚拟机/2. JVM垃圾回收.md#图例)
垃圾回收算法
-
标记清除
将没有被引用的对象标记出来,然后清除。这里的清除并不是把内存空间置零操作,而是把这些空间记录下来,待后面分配空间的时候,去寻找是否有空闲的空间,然后进行覆盖分配。
过程:
- 利用可达性分析,遍历所有对象,标记要回收的对象
- 再遍历一遍,将被标记的对象清除。
优点:速度快
缺点:
-
效率不稳定,如果对象较多,大部分都是要被回收的,就需要做大量的标记
-
清除的空间比较零碎,当待分配的新对象过大,即使零碎空间加起来总共是够的,但是由于过于零散,所以无法对其进行分配。如:新创建了一个数组对象比较大,有四个零碎的空间,但是每一个零碎的空间都满足不了它,但是加起来满足,此时也会产生内存空间不足的问题。
-
标记整理
过程:
- 利用可达性分析,遍历所有对象,标记要回收的对象
- 将所有存活的对象向前移动,将端边界以外的对象都回收掉
优点:没有内存碎片,连续空间比较充足
缺点:涉及到对象地址的改变,开销大,效率低。
-
复制
过程:先标记,再将From上存活的对象复制到To上,回收From上的垃圾,交换From和To
优点:不会有内存碎片
缺陷:始终会占用双倍的内存空间
分代垃圾回收
特点:
-
Jvm将堆分为了新生代和老年代。新生代又分为 伊甸园,幸存区From,幸存区To
-
长时间或频繁使用,放入老年代,特别大的对象,如果新生代存不下也可能直接放入老年代
-
对新生代的垃圾回收更加频繁,对老年代的垃圾回收频率低一些(内存空间不足时,再去清理)
过程:
-
新的对象首先分配在伊甸园区域。
-
当新生代空间不足时,触发minor gc,伊甸园存活的对象使用copy复制到to中,存活的对象年龄加1并且交换from 和 to 所指向的空间,即始终让to空间保持空闲。
-
之后新创建的对象还添加到伊甸园中。当满之后,触发第二次垃圾回收(minor gc),此时如果伊甸园中有垃圾未回收的进入幸存区to,数量+1,from中未被回收的对象,也进入to,数量再+1。之后清除伊甸园和幸存区from的数据,交换幸存区From和To。此时幸存区To,还是空的。
-
当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)。
问题:为什么老年代最大寿命是15?
-
当老年代空间不足,会先尝试触发minor gc,如果之后空间仍不足,那么触发full gc(也会引起stop the world),stop the world的时间更长
-
如果仍然不足,会抛出OutOfMemory异常。
注意:
minor gc 会引发stop the world,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。(因为垃圾回收的时候,会产生对象的地址的改变。)
垃圾回收器
串行
应用场景:
-
底层是一个单线程的垃圾回收器
-
适合堆内存较小,cpu数量少,适合个人电脑
虚拟机设置
-XX:+UseSerialGC=Serial+SerialOld
串行垃圾回收器分为两个部分,分开运行的。新生代空间不足了触发Serial完成MinorGC,老年代空间不足了触发SerialOld完成FullGC。
- Serial
- 新生代
- 复制算法
- SerialOld
- 工作在老年代
- 标记整理算法
执行垃圾回收的时候,用户的线程都会在安全点停下来,等待垃圾回收线程运行,用户的线程都阻塞,当回收完毕后,用户线程再继续运行。
吞吐量优先
应用场景:
-
多线程
-
适合堆内存较大的场景
-
需要多核cpu支持(否则多线程争强一个cpu效率低)
-
让单位时间内,STW的时间最短(一个小时发生了两次垃圾回收,虽然单次垃圾回收时间较长,但总的看时间较短)少餐多食0.2 + 0.2 = 0.4
虚拟机设置
并行的垃圾回收器
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC # 1.8默认的并行垃圾回收机制
-XX:+UseAdaptiveSizePolicy # 自适应策略,开启后会自动取调整新生代占比,晋升阈值等。
-XX:GCTimeRatio=ratio # 调整吞吐量,垃圾回收的时间跟总时间的占比。1/(1+tatio),假如tatio=19,结果=0.05,即每100分钟进行5次垃圾回收,如果达不到这个目标,会调整堆的大小来适配。
-XX:MaxGCPauseMillis=ms # 每次垃圾回收的暂停时间,最大值是200ms。 跟-XX:GCTimeRatio=ratio需要适配,因为堆调大,那么对应的垃圾回收的暂停时间肯定变长。
-XX:ParallelGCThreads=n # 允许并行的垃圾回收线程数量,如果是单核CPU,线程越多相反会造成性能越低。
-
-XX:+UseParallelGC
-
新生代
-
复制算法
-
-
-XX:+UseParallelOldGC
- 老年代
- 标记+整理算法
parallel并行,指的是,多个垃圾回收器可以并行的运行,占用不同的cpu。但是在此期间,用户线程是被暂停的,只有垃圾回收线程在运行。
响应时间优先
应用场景
-
多线程
-
适合堆内存较大
-
需要多核cpu
-
尽可能让单次STW的时间最短(一个小时发生了5次垃圾回收,但是每次的时间都很短)少食多餐 0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5
虚拟机设置
并发的垃圾回收器
-XX:+UseConcMarkSweepGC~ -XX:+UseParNewGC~SerialOld
-XX:ParallelGCThreads=n~ -XX:ConcGCTreads=threads
ParallelGCThreads 表示并行的垃圾回收线程数,一般跟cpu数目相等
-XX:ConcGCTreads 表示用于垃圾回收的线程数,一般是1/4 一条垃圾线程,3条用户线程
-XX:CMSInitiatingOccupancyFraction=percent #cms垃圾回收的时机 80 ,即老年代的内存空间占用了80%的时候进行垃圾回收,以给浮动垃圾一定的空间
-XX:+CMSScavengeBeforeRemark
-
-XX:+UseConcMarkSweepGC
- 老年代
- 标记清除
- 并发
-
-XX:+UseParNewGC
- 新生代
- 复制
-
SerialOld
当cms垃圾回收器并发失败时,会有一个补救措施,让老年代的垃圾回收器UseConcMarkSweepGC转换成一个串行的SerialOld,标记整理的垃圾回收器
并发意味着垃圾回收时,其他的用户线程也可以并发运行,与垃圾回收线程抢占cpu
CMS等垃圾回收结束STW,进一步减少需要STW的时间
并发和并行垃圾回收区别
UseConcMarkSweepGC是并发的垃圾回收器,UseParallelGC 是并行的垃圾回收器。
并发:指垃圾回收器工作的同时,用户线程也可以运行。(减少了一定stop the world 的时间)
并行:指多个垃圾回收期并行运行,但是不允许用户线程运行。
CMS垃圾回收器
CMS是老年代,并发的垃圾回收器。
- 当老年代空间不足时,所有进程运行到安全点暂停,然后垃圾回收的线程进行初始标记,初始标记比较快,只是标记根对象。此过程会Stop The World,阻塞其他用户线程。
- 之后达到下一个安全点,其他用户线程也可以继续运行了,此时垃圾回收线程进行并发标记,即可以跟其他用户线程并发工作,然后将垃圾标记出来。此过程不会STW。并发标记阶段就是从GCRoots的直接关联的对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 达到下一个安全点后,进行重新标记,因为上一个并发标记时,其他用户线程也在并发执行,所有可能会产生新对象新引用,对垃圾回收线程造成了干扰,需要重新标记。此过程会STW。这个阶段的停顿时间会比初始阶段时间稍长一些,但也远比并发标记阶段的时间段少。
- 到下一个安全点后,其他用户进程恢复,垃圾回收线程开始并发地清理垃圾,恢复运行。清理删除标记阶段的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
参数设置:
-XX:ParallelGCThreads=n 表示并行的垃圾回收线程数,一般跟cpu数目相等
-XX:ConcGCTreads=threads 并发的垃圾回收线程数目,一般是ParallelGCThreads的 1/4。即一个cpu做垃圾回收,剩下3个cpu做用户线程。
CMS在执行最后一步并发清理的时候,由于其他线程还在运行,就会产生新的垃圾,而新的垃圾只有等到下次垃圾回收才能清理了。这些垃圾被称为浮动垃圾。所以要预留一些空间来存放浮动垃圾。
-XX:CMSInitiatingOccupancyFraction=percent,开始执行CMS垃圾回收时的内存占比(percent),percent早期默认65,即只要老年代内存占用率达到65%的时候就要开始清理,留下35%的空间给新产生的浮动垃圾。
-XX:+CMSScavengeBeforeRemark 在重新标记之前,把新生代先回收了,就不会存在新生代引用老年代,然后去查找老年代了。
-XX:+UseParNewGC,垃圾回收之后,新生代对象少了,自然重新标记的压力就轻了。
问题
因为CMS基于标记清除,有可能会产生比较多的内存碎片。这样的话,会造成将来给对象分配空间时,空间不足时,如果minorGC后内存空间也不足。那么由于标记清除,老年代的空间也不足,造成并发失败。于是CMS退化成SerialOld串行地垃圾回收,通过标记整理,来得到空间。但是这样会导致垃圾回收的时间变得很长(要整理),结果本来是响应时间优先的回收器,响应时间长,给用户造成不好的体验。
G1 垃圾回收器
内存划分,大对象如何存储,如何回收垃圾,跨region的引用
特点:
-
G1将堆划分成多个大小相等的region,每一个region可以根据需要,进行新生代(伊甸园,幸存区),老年代的更换。
region的大小:-XX:G1HeapRegionSize(1-32MB 2的N次幂)
-
Region中有一块特殊的区域,Humongous区域,用于处理大对象(大小超过region一半即视为大对象)对于那些超过region的超级大对象,存在连续的N个Humongous中,G1大多数行为都把Humongous当做老年代的一部分看待。
-
G1提供Mixed GC,去跟踪各个Region里面的垃 圾堆积的“价值”大小,哪块内存中的垃圾数量最多,回收收益最大就去回收哪个。其他垃圾处理器,回收要么是整个新生代(Minor GC),要么是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。
-
Region每次回收都是将Region作为单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍,避免在整个Java堆中进行全区域的垃圾收集。
-
G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默 认值是200毫秒),优先处理回收价值收益最大的那些Region。
-
对于跨Region的对象引用,G1使用记忆集避免全堆作为GC Roots扫描。
记忆集:每个Region都有自己的记忆集,记录了哪些Region指向我以及我指向了哪些Region
因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
回收过程
整体上是标记+整理算法,两个区域之间是复制算法。
-
**初始标记:**老年代内存不足时,会触发MinorGC,此时会STW,MinorGC的同时,GC Root会进行初始标记。这个阶段Minor GC 和 初始标记是同步完成的。
-
**并发标记:**从GC Root开始对堆中的对象进行可达性分析,遍历整个堆,找到要回收的对象,这个阶段用户线程还在运行,不会STW,此阶段耗时较长
-XX:InitiatingHeapOccupancyPercent=percent(默认45%) # 设置阈值,整个老年代占到堆空间45%时会进行并发标记
-
**最终标记:**用户线程暂停STW,标记并发阶段结束后,用户线程产生的垃圾。
-
**筛选回收:**对各个Region的回收价值和成本排序,根据用户期望的停顿时间制定回收计划,将决定回收的Region的存活对象复制到新的Region,再清理掉整个旧Region的全部空间。用户线程停止STW,多条收集器线程并行完成。
对于老年代的垃圾回收来说,并不会全部回收。为了达到暂停时间短(STW),会优先让一部分垃圾回收价值高的老年代回收。与MaxGCPauseMillis参数有关
-XX:MaxGCPauseMillis=ms 停顿时间
使用场景
- 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是200ms,也是并发回收器。
- 用于超大堆内存,会将堆划分为多个大小相等的Region
-
内存较小时,G1和cms性能差不多
-
内存较大时,G1性能更好
-
将一个大的内存划分成一些小的区域。
-
参数设置
-XX:+UseG1GC # jdk8及之前,开启G1回收器 1.9默认
-XX:G1HeapRegionSize=size # 设置区域大小 1 2 6 8
-XX:MaxGCPauseMillis=time # 暂停目标-XX:InitiatingHeapOccupancyPercent=percent(默认45%) # 设置阈值,整个老年代占到堆空间45%时会进行并发标记 实际中要调整,目的是尽早地开始垃圾回收,避免Full GC的发生。
当老年代内存不足时,新生代的回收之后,可以在进行新生代回收时,同时并发标记,然后再进行混合垃圾回收,即对新生代、老年代都进行一次较大的垃圾回收。
Full GC
- SerialGC
- 新生代内存不足发生的垃圾收集 ——minor gc
- 老年代内存不足发生的垃圾收集 ——full gc
- ParallelGC
- 新生代内存不足发生的垃圾收集 ——minor gc
- 老年代内存不足发生的垃圾收集 ——full gc
- CMS
- 新生代内存不足发生的垃圾回收 ——minor gc
- 老年代内存不足
- 当垃圾回收速度跟不上垃圾生成速度时,会full gc
- 并发收集失败前是minor gc,并发失败退化为串行垃圾收集器,触发full gc
- G1
- 新生代内存不足发生的垃圾回收 ——minor gc
- 老年代内存不足(阈值达到45%,会并发标记)
- 当垃圾回收速度跟不上垃圾生成速度时,会full gc
- 并发收集失败前是minor gc,并发失败退化为串行垃圾收集器,触发full gc
调优
调优准则
调优领域:内存,锁竞争,cpu占用,io
尽量避免发生GC
调优前,先判断是否自己代码的问题,造成的内存占用过高。先排除自己代码的问题,再进行内存调优。
- 数据是不是太多(查表时,查询表中的所有数据)
- resultSet = statement.executeQuery(“select * from 大表”),加载到堆内存应该limit,避免把不必要的数据加载到java内存中
- 数据表示是否太臃肿(只查找用户的手机号,但是把用户的所有信息都查找了出来)
- 对象图(用到对象的哪个属性就查哪个)
- 对象大小 至少16字节,Integer包装类型24字节,而int 4字节
- 是否存在内存泄露
- static Map map作为缓存等,静态的,长时间存活的对象,一直添加,会造成OOM
- 可以用软引用、弱引用
- 可以使用第三方的缓存实现,redis等,不会给java堆内存造成压力
新生代调优
调优先在新生代进行调优,当new一个对象的时候,先在伊甸园中分配。
每个线程都会再伊甸园中分配一块私有的区域thread-local allocation buffer,allocation buffer分配了一个缓冲区,当我们创建对象的时候,会先检查缓冲区有没可用内存,有的话先在这里内存分配。
新生代的特点
- 所有的new操作的内存分配非常廉价
- TLAB thread-local allocation buffer
- 死亡对象的回收代价是零
- 复制算法,复制之后直接释放空间,不整理
- 大部分对象用过即死
- MinorGC的时间远远低于FullGC
问题
- 新生代小了,容易MinorGC
- 新生代太大,老年代空间不足,会触发FullGC,消耗更多空间。
调优
-
新生代最好能容纳所有【并发量 X (请求响应)】
- 假如一次请求,我们需要创建的资源大小为512k,如果同一时刻有1000条请求,那么我们新生代的内存大小最好为512k*1000的内存大小。这样可以较少调用垃圾回收。
-
幸存区大到能保留【当前活跃对象+需要晋升对象】
- 幸存区中考虑有两类对象,一类时肯定晋升老年代,一类是马上要被回收。幸存区的大小要大到二类对象都能够容纳。
- 为防止某些对象提前进入老年代,但老年代内存空间不足的时候,调用Full GC 清除内存。
-
晋升阈值配置要得当,让长时间存活对象尽快晋升
- 因为晋升对象如果长时间存在于幸存区,每次垃圾回收进行复制其实都没必要。应该早点把待晋升对象晋升到幸存区。
- -XX:MaxTenuringThreshold=threshold 晋升阈值,默认15
- -XX:+PrintTenuringDistribution 打印幸村区不同年龄的对象
老年代调优
以CMS为例
- CMS的老年代内存越大越好
- 先尝试不做调优,如果没有FullGC那么说明老年代空间比较富裕,运行状况还不错。及时出现了FullGC,也可以先尝试调优新生代
- 观察发生FullGC时老年代内存占用,将老年代内存预设调大1/4~1/3
- -XX:CMSInitiatingOccupancyFraction=percent
- 待空间达到了老年代的多少进行垃圾回收,预留空间给浮动垃圾
GC场景
案例1 FullGC和MinorGC频繁
- 说明空间紧张
- 可能是新生代空间小,当高峰期时对象的频繁创建,导致频繁发生MinorGC
- 由于新生代空间紧张,动态调整晋升寿命,导致存活时间较短的对象也会晋升到老年代,导致触发FullGC
- 应尝试调节新生代的内存
案例2 请求高峰期发生FullGC,单次暂停时间特别长(CMS)
- 通过查看GC日志,查看CMS哪个阶段耗时长
- 当高峰期时,对象频繁创建。在CMS的重新标记阶段,就可能耗费大量时间。
- 可以在重新标记之前,先进行一次垃圾回收
- -XX:+CMSScavengeBeforeRemark
案例3 老年代充裕情况下,发生FullGC()
- 1.7之前是永久代作为方法区的实现,可能会发生永久代不足。
- 永久代不足会触发堆的FullGC
优化:JDK 8u20字符串去重
- 优点:节省大量空间
- 缺点:略微多占用了cpu空间,新生代回收时间略微增加
-XX:+UseStringDeduplication:开启字符串去重功能,G1自动开启。
String s1 = new String("hello"); //char[]{'h','e','l','l','o'}
String s1 = new String("hello"); //char[]{'h','e','l','l','o'}
- 将所有新分配的字符串放入一个队列
- 当新生代回收时,G1并发检查是否有字符串重复
- 如果它们值一样,让它们引用同一个char[](注意,是引用同一个char,而不是同一个string)
- 注意,与String.intern()不一样
- String.intern()关注的是字符串对象
- 而字符串去重关注的是char[]
- 在JVM内部,使用了不同的字符串表
优化:JDK 8u40并发标记类卸载
之前:类加载后,一致占用内存,没办法卸载。一些自定义的类加载器,使用一段时间就不使用了,但是一直占用着内存。
增强:所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类。
卸载条件:1.类的实例都被回收掉2.类加载器中所有的类不再使用。3.jdk的类加载器(启动,扩展,应用程序类加载器)不会卸载,自定义的类加载器会卸载。
-XX:+ClassUnloadWithConcurrentMark # 默认启用
优化:JDK 8u60回收巨型对象
- 一个对象大于region的一半时,称之为巨型对象
- 巨型对象可能会占用多个region
- G1不会对巨型对象进行拷贝
- 回收时被优先考虑
- G1会跟踪老年代所有incoming引用,这样老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉。
- 老年代的卡片对巨型对象的引用为0时,矩形对象可以在新生代回收。
优化:JDK 9并发标记起始时间的调整
- 并发标记必须在堆空间占满前完成,否则退化为FullGC
- JDK9之前需要使用 -XX:InitiatingHeapOccupancyPercent 老年代与堆内存的占比。
- JDK9可以动态调整阈值
- -XX:InitiatingHeapOccupancyPercent 用来设置初始值
- 进行数据采样并动态调整(初始设置的可能时45%,但是实际中会有一定的调整)
- 总会添加一个安全的空档空间
目的是尽早地开始垃圾回收,避免Full GC的发生。
优化:JDK 9更高效的回收
- 250+增强
- 180+bug修复
- https://docs.oracle.com/en/java/javase/12/gctuning
3. 编译器处理
语法糖
java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利
语法糖处理
详情参考文章:[mystudy/Java/G-虚拟机/Java虚拟机/5. 编译期处理.md · Zhang-HaoQi/Knowledge - 码云 - 开源中国 (gitee.com)](https://gitee.com/zhang-haoqi/knowledge/blob/develop/mystudy/Java/G-虚拟机/Java虚拟机/5. 编译期处理.md)
-
默认构造器
-
自动拆装箱
-
泛型集合取值即泛型擦除
-
可变参数: String… args其实是一个 String[] args
-
foreach循环
- 数组:编译后会被替换成普通的for循环、
- 集合:编译后会被替换成迭代器的方式
-
switch循环
-
string:编译后会变成两个switch,第一个switch比较字符串的hashcode,第二个用于equals比较。
原因:hashCode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突
-
枚举:被转成一个数组,按枚举顺序将元素以int(1,2,3排序)的形式放入数组,之后switch(数组的元素),来对应case进行输出
-
-
枚举
被替换成final类,枚举的实例都被定义为static final类型,并放入一个数组中。
-
try catch 自动释放资源
try(资源变量 = 创建资源对象),结束后,不需要我们手动释放IO资源,原因在于编译时,生成的代码中,在finally中自动帮我们释放了资源
-
重写的桥接
- 父子类的返回值完全一致
- 子类返回值可以是父类返回值的子类
-
匿名内部类被优化为final修饰的类。
4. 类加载
类加载
详细参考:[mystudy/Java/G-虚拟机/Java虚拟机/6. 类加载.md · Zhang-HaoQi/Knowledge - 码云 - 开源中国 (gitee.com)](https://gitee.com/zhang-haoqi/knowledge/blob/develop/mystudy/Java/G-虚拟机/Java虚拟机/6. 类加载.md#验证)
虚拟机通过类加载器将描述类的字节码数据加载到内存里,并对数据进行校验,解析,初始化,最终变成可以直接被虚拟机使用的class对象。
类加载阶段:
加载——链接(验证,准备,解析)——初始化——使用——卸载
-
加载:通过类的全限定性类名,获取类的二进制流,将二进制流的静态存储结构转为方法区的运行时数据结构,在堆中为该类生成一个class对象。
-
验证:验证class文件的二进制流是否符合虚拟机的要求,不会威胁虚拟机安全。如确认魔数。
-
准备:为 static 变量分配空间,设置默认值
static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段(cinit方法时初始化)完成
如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
-
解析:类加载时,类的字节码载入方法区。解析时,将常量池中的符号引用解析为直接引用(类在内存中的真实地址)
-
初始化:调用构造器过程,执行类中定义的Java代码。
类加载器
分类
- 启动类加载器:Bootstrap ClassLoader
- 扩展类加载器:Extension ClassLoader
- 应用类加载器:Application ClassLoader
- 自定义类加载器
双亲委派模型:
当一个类加载器收到一个类加载的请求,他首先不会尝试自己去加载,而是将这个请求委派给父类加载器去加载,只有父类加载器在自己的搜索范围类查找不到给类时,子加载器才会尝试自己去加载该类。
为了防止内存中出现多个相同的字节码,如果没有双亲委派,用户就可以自己定义一个java.lang.String 类,无法保证类的唯一性。
如何打破双亲委派机制?
自定义类加载器,重写ClassLoad类。
如果打破,则重写loadclass方法。
如果不打破,则重写findclass方法,如果父加载器加载不到类,则使用findclass。
自定义类加载器使用场景
- 加密:如果你不想自己的代码被反编译的话。(类加密后就不能再用ClassLoader进行加载了,这时需要自定义一个类加载器先对类进行解密,再加载)。
- 从非标准的来源加载代码:如果你的字节码存放在数据库甚至是云端,就需要自定义类加载器,从指定来源加载类。
- **隔离加载类:**在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。
- 修改类加载的方式:类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载。
5. 内存模型
详细参考:[mystudy/Java/G-虚拟机/Java虚拟机/8. 内存模型.md · Zhang-HaoQi/Knowledge - 码云 - 开源中国 (gitee.com)](https://gitee.com/zhang-haoqi/knowledge/blob/develop/mystudy/Java/G-虚拟机/Java虚拟机/8. 内存模型.md)
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障。
原子性保障
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果非0
原因:
对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i
i–操作
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i
单线程时,i++和i–顺序执行,不会产生错乱。
多线程时,不同的线程是抢占cpu运行,cpu运行有时间片,时间片一到,就该切换线程,然后所有线程又来抢占cpu的时间片,也有可能同一个线程多次抢到。
如果i++操作没有执行完,线程切换,进行i–,就可能发生了数据错乱的问题。
此处是静态变量,与局部变量区分开
局部变量i++是在槽位上直接自增iinc
静态变量i++,是getstatic加载到操作数栈中,然后iadd做加法加上iconst_1常量1
不同的线程在自己的操作数栈中操作,操作完后,把结果存入主内存。对于其他线程的操作,本线程是不知道的。同时读入0,我加1,你减1,我在进行加操作时的时候,我是不知道你已经把i变成-1并且放到主内存中了。我只会把i变成1,然后放入主内存中,然后覆盖你。
解决方案
使用synchronized
synchronized可以保证原子性和可见性。即获得锁的线程,在任务执行完毕后,才会释放锁,其他线程必须抢到锁,才能进行数据的操作。同时,数据操作完毕后,会将操作的数据刷新到主存,保证可见性。
可见性
线程操作在主存中的共享数据时,会将主存中的数据拷贝到当前虚拟机栈中操作,之后当前线程操作该数据,都是在栈中进行操作中,操作完成后将数据刷新到主存。当主存中的数据发生变化时,其他线程读取数据还是从当前的虚拟机栈中读数据,并不是从主存读取,因此无法获取到主存最新的数据。
解决方式:
volatile关键字
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
volatile保证可见性和有序性,但是并不保证原子性。
有序性
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;
在同一线程下,以上转换是没有问题的,但是在多线程下『指令重排』会影响正确性.
解决
volatile 修饰的变量,可以禁用指令重排
双检索
这种特性称之为『指令重排』,在同一线程下,以上转换是没有问题的,但是在多线程下『指令重排』会影响正确性,例如著名的 double-checked locking(双重检查锁) 模式实现单例
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
但在多线程环境下,上面的代码是有问题的(指令重排问题,防止指令重排则使用volatile修饰INSTANCE变量), INSTANCE = new Singleton() 对应的字节码为:
其中 4 7 两步的顺序不是固定的,也许 jvm 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间序列执行:
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。
volatile实现原理:
【70期】面试官:对并发熟悉吗?谈谈对volatile的使用及其原理-Java知音 (javazhiyin.com)
volatile为了防止重排序,在代码运行时,通过JIT添加内存屏障的方式,在对共享变量修改时,都会在其前添加内存屏障,防止屏障之后的代码重排序到屏障之前,从而防止指令重排。
可见性也是通过lock屏障的方式,对于lock屏障之后的操作,操作完都刷新到主存中。
CAS与原子类
无锁并发。CAS 即 Compare and Swap ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:
旧值(主存中共享变量的值),预测值,新值
如果旧值==预测值,则旧值=新值。
特点:
- 获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
可能产生的问题:
-
ABA问题:变量A,线程1旧值为A,预测值为A。线程B将变量A修改为了B,之后又修改成了A,此时线程1会认为变量A没有发生变化,赋予新值。
解决:使用AtomicStampedReference,带有标记的原子引用类,可通过控制变量版本来保证CAS的正确性
-
循环时间长,耗费性能:如果旧值!=预测值,那么线程会尝试重新赋值。
-
只能对一个变量进行CAS操作
解决:使用互斥锁或者将多个变量封装成一个对象。
进阶知识点
1. 逃逸分析
Java中的对象不一定是在堆上分配的,因为JVM通过逃逸分析,能够分析出一个新对象的使用范围,并以此确定是否要将这个对象分配到堆上。
逃逸分析的情况:
- 对象被复制给成员变量或者静态变量,可能被外部使用,此时变量就发生了逃逸。
- 对象通过return语句返回。
逃逸分析优点:
-
对象可能分配在栈上
对象可能分配在栈上,可在栈帧结束后快速销毁,减少JVM回收压力
-
分离对象或标量替换
当JVM通过逃逸分析,确定要将对象分配到栈上时,即时编译可以将对象打散,将对象替换为一个个很小的局部变量,我们将这个打散的过程叫做标量替换。将对象替换为一个个局部变量后,就可以非常方便的在栈上进行分配了。
-
同步锁消除
如果JVM通过逃逸分析,发现一个对象只能从一个线程被访问到,则访问这个对象时,可以不加同步锁。如果程序中使用了synchronized锁,则JVM会将synchronized锁消除。
开启同步锁消除:-XX:+EliminateLock
搭配逃逸分析参数:XX:+DoEscapeAnalysis
注意:针对的是synchronized锁,而对于Lock锁,则JVM并不能消除
所以,并不是所有的对象和数组,都是在堆上进行分配的,由于即时编译的存在,如果JVM发现某些对象没有逃逸出方法,就很有可能被优化成在栈上分配。
2. 为什么对象寿命大于15进入老年代
Java对象除自身实例数据外,还有对象头和对齐字节。
其中MarkWord信息如下:用于存储对象自身的运行时数据
这就是一个MarkWord,其中对象的分代年龄占4位,也就是0000,最大值为1111也就是最大为15.而不可能为16或者17之类的。
3. 线程内的OOM是否会影响主线程的运行?
当一个线程抛出OOM异常后,它所占据的内存资源全部会被释放掉,从而不会影响其他线程的运行。
4. JIT优化
前端编译器:将java源代码文件编译成字节码文件。更着重提升开发效率,一次编译,到处运行。
即时编译器:运行期把字节码转变成本地机器码的过程。更着重程序运行期间性能优化
提前编译器:直接把程序编译成与目标机器指令集相关的二进制代码的过程。更注重程序第一次启动时,启动较慢的情况。
即时编译器(JIT)与解释器的区别
- 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- JIT 是将一些热点字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
- 解释器是将字节码解释为针对所有平台都通用的机器码
- JIT 会根据平台类型,生成平台特定的机器码
JIT优化:
-
JIT 是将一些热点字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再解释。
-
逃逸分析
-
锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。(如对局部变量加锁)
-
锁粗化:锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是加锁粒度过小导致某条线程重复性的加锁和释放锁。(如对for循环内部的i加锁,就会被优化为对整个for循环加锁)
-
指令重排:
即时编译器会在不影响正确性的前提下,可以调整语句的执行顺序。将耗时的操作,靠后执行。
-
volatile实现:
volatile为了防止重排序,在代码运行时,通过JIT添加内存屏障的方式,在对共享变量修改时,都会在其前添加内存屏障,防止屏障之后的代码重排序到屏障之前,从而防止指令重排。
可见性也是通过lock屏障的方式,对于lock屏障之后的操作,操作完都刷新到主存中。