JVM学习笔记

JVM


image-20221117224858429

第一章、内存结构


1、程序计数器


定义

Program Counter Register 程序计数器(寄存器)

作用

  • 记住下一条 JVM 指令的地址

特点

  • 线程私有
  • 没有内存溢出问题

Java源代码经过编译成为二进制字节码(JVM指令),二进制字节码经过解释器翻译为机器码,机器码交给CPU执行;程序计数器 (通过寄存器来实现) 在解释器执行时将下一条指令地址记住,解释器下次就会根据程序计数器中指令地址区执行下一条指令。

2、虚拟机栈


定义

Java Virtual Machine Stacks (Java 虚拟机栈)

  • 每个线程运行时需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧(正在运行的方法),对应着当前正在执行的那个方法

问题分析

  1. 垃圾回收是否涉及栈内存?

    没有涉及,栈帧在运行完方法时将方法弹出栈,被自动回收掉,根部不需要垃圾回收。
    
  2. 栈内存是越大越好吗?

    不是,栈内存越大,会让线程数目变少,因为物理内存是一定的。
    
  3. 方法内的局部变量是否线程安全?

    1. 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的。
    2. 如果是局部变量引用了对象,并逃离了方法的作用范围,需要考虑线程安全。(传入对象且返回对象需要考虑安	全)。如果是基本数据类型的变量则不需要考虑。
    

栈内存溢出

  • 栈帧过多导致栈内存溢出
  • 栈帧过大导致栈内存溢出

线程运行诊断(Linux)

定位:

  • 用 top 定位到哪个进程对CPU的占用过高

  • ps H -eo pid, tid,%cpu | grep 进程id (用ps命令进一步定位到哪个线程引起的CPU占用过高)

  • jstack 进程 id(十进制)

    • 可以根据线程id(十六进制)找到有问题的线程,进一步定位到问题代码的行号

3、本地方法栈


本地方法接口:不是由 Java 编写的方法

调用本地方法时就是使用的本地方法栈

native method

例如:Object类的 wait、notify、notifyAll、hashCode、clone等方法

4、堆(Heap)

定义

Heap 堆

  • 通过 new关键字,创建对象都会使用堆内存

特点

  • 他是线程共享的,堆中的对象都需要考虑线程安全的问题
  • 有垃圾回收机制

堆内存溢出

对象的内存满了,就会溢出。就像一直在一个List中添加数据,就会导致堆内存溢出

4.1 堆内存诊断

jps 工具

  • 查看当前系统中有哪些 Java 进程
  • 列出所有正在运行的 Java 进程,其中 jps 命令也是一个 Java 程序,前面的数字就是对应的进程 id

jmap工具

  • 查看堆内存占用情况 jmap -heap 进程号

jconsole工具

  • 图形化界面,多功能的检测工具,可以连续检测

jvisualvm工具

  • 也是一个可视化工具,功能更加强大

5、方法区(Method Area)


特点:

  • 线程共享的区域
  • 启动时创建
  • 存储跟类结构相关的信息:属性、方法、构造方法
5.1 组成

1.6 版本:PermGen 永久代 (实现)

1.7 版本及以后:Metaspace 元空间(实现)

永久代:字符串常量池在方法区

方法区在 JVM 内存中

元空间:字符串常量池在堆中

方法区在本地内存

image-20221119163802128
5.2 方法区内存溢出

  • 1.8 之前会导致永久代内存溢出

    演示永久代内存溢出java. lang. OutOfMemoryError: PermGen space

    -XX:MaxPermSize=8m
    
  • 1.8 之后会导致元空间内存溢出

    延时元空间内存溢出java.lang.OutOfMemeoryError:Metaspace

    -XX:MaxMetaspaceSize=8m
    
5.3 运行时常量池

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

二进制字节码(类基本信息,常量池,类方法定,包含了虚拟机指令)

image-20221119193025365 image-20221119193252910 image-20221119193332342
5.4 StringTable (重点)

程序运行时会将常量池中的字符串放入StringTable(串池)

字符串在 Java 程序中被大两使用,为了避免每次都创建相同的字符串对象以及内存分配, JVM 内部对字符串对象的创建做了一定的优化(以组指针指向Heap中的String对象的内存地址)

1.7 版本之前StringTable放在方法区中,1.7之后放在堆中。原因:方法区的内存空间大小

public class Demo {
    // StringTable["a", "b", "ab"] hashtable结构,不能扩容
    
    public static void main(String[] args) {
        //常量池中的信息,都会被加载到运行时常量池中,这时a b ab 都是常量池中的符号,还没有变为 java字符串对象
        // ldc #2 会把 a 符号变为"a"字符串对象
        // ldc #3 会把 b 符号变为"b"字符串对象
        // ldc #4 会把 ab 符号变为"ab"字符串对象
        
        String s1 = "a"; // 懒惰的
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2; // new StringBuilder.append("a").append("b").toString --> new String("ab")
        System.out.println(s3 == s4); //false
    }
    
}

StringBuilder的toString方法:

@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}
5.4.1 StringTable特性

  • 常量池中的字符串仅是符号,在被用到时才会转化为对象

  • 利用串的机制,避免创建重复的字符串对象

  • 字符串变量拼接的原理是StringBuilder(1.8)

  • 字符串常量的拼接原理是编译器优化(编译时会先去串池中查看是否有这个字符串对象,有的话就不用创建)

  • 使用 intern 方法:主动将串池中还没有的字符串对象放入串池

    • 1.8 是将字符串尝试放入串池,如果有则不会放入,如果没有则会放入串池,会把串池的对象返回

      注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

    • 1.6 将字符串对象尝试放入串池,如果有则不会放入,如果没有会把此对象复制一份,放入串池,会把串池中的对象返回

      注意:此时无论调用intern方法是否成功,串池中的字符串和堆内存中的字符串都不是同一个对象

5.4.2 StringTable位置

1.7版本之前StringTable放在方法区中,1.7之后放在堆中。原因:方法区的内存空间太小。

image-20221119212058919
  • 设置堆内存大小

    -Xmx 10m
    
  • 设置永久区内存限制

    -XX:MaxPermSize=10m
    
  • 打印并查看串池信息

    -XX:+PrintStringTableStatistics
    
5.4.3 StringTable垃圾回收

StringTable在内存紧张时,会发生垃圾回收

5.4.4 StringTable性能调优

  • 设置桶个数

    -XX:StringTableSize=桶个数
    

如果字符串的个数较多,可以增加桶个数来提高性能

6、直接内存


特点:

不属于 JVM 管理

  • 常见于NIO(new Input / Output)操作时,用于数据缓冲区
  • 分配回收成本高,但读写性能高
  • 不受 JVM 内存回收管理
6.1 ByteBuffer

使用ByteBuffer比使用io的性能更高

在没有用ByteBuffer时,系统的内部操作时下面这样的

image-20221119224721061

使用了直接内存后,系统内部操作如下图。不再需要经过系统缓存区传给java缓冲区,他们共同划出一块缓冲区,java代码和系统都可以直接访问,大大的提升了效率。少了缓冲区的复制操作。

image-20221119224802033
6.2 直接内存溢出

image-20221119224902374
6.3 直接内存释放原理

直接内存的回收不是通过JVM的垃圾回收来释放的,拿到Unsafe对象,然后调用去分配和调用内存

6.4 分配和回收原理
  • 使用了Unsafe 对象完成直接内存的分配回收,并主动回收需要主动调用freeMemory方法
  • ByteBuffer的实现类内部,使用了Cleaner(虚引用)来监测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存
6.5 禁用显示回收对直接内存的影响
-XX:+DisableExpIicitGC  显式的禁用手动gc

使用上面命令后,代码中调用显式回收将没有作用

System.gc();

第二章、垃圾回收


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


1.1 引用计数法

定义:

只要对象被引用就 + 1, 引用两次就 +2,如果某个变量不再引用就 -1,当对象引用计数为 0 的时候就会被垃圾回收

弊端:

当出现循环引用时,A对象引用B对象,B对象引用计数 +1,B对象引用A,A对象引用计数 +1。当没有谁再引用他们,他们不能被垃圾回收,因为引用计数没有归零。Python 在早期的垃圾回收用的就是引用计数法

1.2 可达性算法

定义:

  • Java 虚拟机中的垃圾回收器采用的是可达性分析算法
  • 扫描堆中的对象,看是否能够沿着GC Root(根对象)为起点的引用链找到该对象,找不到就可以进行垃圾回收

哪些对象可以作为 GC Root?

  • System Class
  • Native Stack(本地栈)
  • 锁(同步锁机制)
  • Thread(活动线程)
    • Java 虚拟机栈中引用的对象
    • 本地方法栈中的 JNI (native 方法)引用的对象
    • 方法区中的静态属性引用的对象(一般指被 static 修饰的对象,加载类的时候就加载到内存中)
    • 方法区中的常量引用的对象(Object)

如何查看 GC Root 对象?

通过 MAT 工具(Eclipse的 Memory Analyzer)

1.3 四种引用

image-20221120160220638

强引用:

只有GC Root 都不引用该对象时,才会回收强引用对象

软引用:

有用但非必须的引用

  • 当GC Root 不再指向软引用对象时,且内存不足时,会回收软引用所引用的对象
  • 可以配合引用队列来释放软引用自身

如上图 B 对象不再引用 A2 对象且内存不足时,软引用所引用的 A2 对象会被回收

软引用本身不会被清理,需要使用引用队列

public class Demo1 {
	public static void main(String[] args) {
		final int _4M = 4*1024*1024;
		//使用引用队列,用于移除引用为空的软引用对象
		ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
		//使用软引用对象 list和SoftReference是强引用,而SoftReference和byte数组则是软引用
		List<SoftReference<byte[]>> list = new ArrayList<>();
		SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M],queue);

		//遍历引用队列,如果有元素,则移除
		Reference<? extends byte[]> poll = queue.poll();
		while(poll != null) {
			//引用队列不为空,则从集合中移除该元素
			list.remove(poll);
			//移动到引用队列中的下一个元素
			poll = queue.poll();
		}
	}
}

弱引用:

  • 当 GC Root 不再指向弱引用对象时,不管内存是否不足,都会回收弱引用所引用的对象
  • 可以配合引用队列来释放弱引用本身

弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference

虚引用:

必须配合引用队列使用,主要配合 ByteBuffer 使用,当虚引用对象所引用的对象被回收之后,虚引用对象会被放入引用队列中,由 Reference Handler 线程调用虚引用相关的方法释放直接内存

  • 虚引用的一个体现是释放直接内存所分配的内存,当被引用对象 ByteBuffer 被垃圾回收以后,虚引用对象 Cleaner 就会被放入引用队列中,然后调用 Cleaner 的 clean 方法来释放直接内存
  • 如上图,B 对象不再引用 ByteBuffer 对象,ByteBuffer 就会被回收。但是直接内存中的内存还没有回收。这时需要将虚引用对象 Cleaner 放入队列中,然后调用它的 clean 方法来释放直接内存

终结器引用:

无需手动编码,在其内部配合引用队列使用

在垃圾回收时,终结器引用入队(被引用对象 暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用他的 finalize 方法,第二次调用 GC 时才能回收被引用对象

  • 当某个对象不再被其他对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用找到被引用的对象,然后调用被引用对象的 finalize 方法。调用之后,该对象再第二次 GC 就可以被垃圾回收了
  • 如上图,B 对象不再引用 A4 对象。这时终结器引用对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的 finalize 方法,调用之后,该对象就可以被垃圾回收了

引用队列:软引用和弱引用可以配合引用队列;虚引用和终结器引用必须配合引用队列使用

2、垃圾回收算法


2.1 标记清除算法

image-20221120164406779

定义:在执行垃圾回收时,先标记完引用对象,然后垃圾收集器根据标记清除没有被标记的对象

优点:速度快

缺点:容易产生大量的内存碎片,如上图,清理没有引用的对象后,会存在内存的空间浪费

2.2 标记整理算法

image-20221120164726873

定义:在执行垃圾回收时,先标记完引用对象,然后清除没有被引用的对象,最后整理剩余时间,避免因内存碎片导致的问题

优点:不会存在内存碎片

缺点:速度慢,因为整理内存是为了避免内存浪费,所以整理需要消耗一定的时间,导致效率较低

2.3 复制算法

image-20221120165003987

定义:将内存分为两个等大小的区域,FROM 和 TO。先将 FROM 中被 GC Root 引用的对象进行标记,将存活的对象从 FROM 放入 TO 中,再回收 FROM 区域中没有被引用的对象。然后交换 FROM 和 TO

优点:这样避免了内存碎片的问题

缺点:需要双倍的内存空间

3、分代垃圾回收


image-20221120170514407
  • 对象首先分配在伊甸园区
  • 新生代空间不足时,触发 Minor GC,伊甸园和 FROM 存活的对象使用 copy 复制到 TO 中,存活的对象年龄 + 1 并且交换 FROM TO
  • Minor GC 会引发 stop the world,暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象的寿命超过阈值,会晋升老年代,最大寿命是 15(4 bit)
  • 当老年大空间不足时,先尝试触发 Minor GC,如果之后空间仍然不足,那么触发 Full GC,STW 的时间更长。如果空间还不足就会触发 OutOfMemory

4、垃圾回收器


4.1 相关概念

并行执行的线程之间不存在切换;并发系统会根据任务调度系统给线程分配线程的 CPU 执行时间,线程的执行会进行切换

并行收集:

并行:多个事情同一个时刻进行,在同一个时刻,有多个程序在多个处理器上运行(每个处理器运行一个程序)

并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

并发收集:

并发:指在某时刻只有一个事件在发生,某个时间段内由于 CPU 交替执行,可以发生多个事件,在同一 CPU 上同时运行多个程序

指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续执行,而垃圾收集程序在另一个 CPU上

吞吐量:

即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))

例如:虚拟机共运行 100 分组,垃圾收集器花掉 1 分钟,那么吞吐量就是 99%

4.2 串行回收器

-XX:+UseSerialGC = Serial + SerialOld
image-20221120220158318

特点:

  • 单线程
  • 内存较小,个人电脑(CPU 核数较少)
  • 安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
  • 因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态

Serial 收集器:

Serial 收集器是最基本的、发展历史最悠久的收集器

特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个CPU的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有工作线程,直到它结束(Stop The World)

ParNew 收集器:

ParNew 收集其实就是 Serial 收集器的多线程版本

特点:多线程、ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,采用 复制算法,在 CPU 非常多的环境中,可以使用 XX:ParallelGCThreads 参数来限制垃圾收集的线程数。和 Serial 收集器一样存在 Stop The World 问题

Serial Old 收集器:

Serial Old 是 Serial 收集器的老年代版本

特点:同样是单线程收集器,采用标记-整理算法

4.3 吞吐量优先

-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
-XX:GCTimeRatio=ratio
-XX:MaxGCPauseMillis=ms
-XX:ParallelGCThreads=n
image-20221120221205338
  • 多线程
  • 堆内存较大,多核 CPU
  • 单位时间内,STW时间最短
  • JDK 1.8 默认使用的垃圾回收器

Parallel Serial 收集器:

与吞吐量关系密切,故也称为吞吐量优先收集器

特点:

属于新生代收集器,也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与ParNew收集器类似)该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)

GC自适应调节策略

Parallel Scavenge 收集器可以设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节模式称为GC的自适应调节策略。

Parallel Scavenge 收集器使用两个参数控制吞吐量:

  • XX:MaxGCPauseMills 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小

Parallel Old 收集器:

是Parallel Scavenge收集器的老年代版本

特点:多线程,采用标记-整理算法(老年代没有幸存区)

4.4 响应时间优先

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark
image-20221120222459075
  • 多线程
  • 堆内存较大,多核CPU
  • 尽可能让单次STW时间变短(尽量不影响其他线程运行)

CMS 收集器:

Concurrent Mark Sweep,一种获取最短回收停顿时间为目标的老年代收集器

特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片

应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下,比如 WEB 程序、B/S程序

CMS 收集器的运行过程分为下列 4 步:

  • 初始标记:标记GC Root对象。速度很快但是仍然存在STW问题

  • 并发标记:进行GC Root Tracing的过程,找出GC Root对象所关联的对象且用户线程可以并发执行

  • 重新标记:为了修复并发标记期间因用户程序继续运行而导致产生变动的那一部分对象的标记记录(可达对象变为不可达)。仍然存在 STW 问题

  • 并发清除:对没有标记的对象进行清除回收

CMS 收集器的内存回收过程是与用户线程一起并发执行的

4.5 G1(Garbage First)

定义:

Garbage First

  • 2004年论文发布
  • 2009 JDK6u14体验
  • 2012 JDK 7u4 官方支持
  • 2017 JDK 9 默认

使用场景:

  • 同时注重吞吐量(Throughput)和低延迟(Low latency),默认暂停时间是200ms
  • 超大堆内存,会将堆划分为多个等大的Region
  • 整体上是标记-整理算法,两个Region(区域)之间是复制算法

相关 JVM 参数:

-XX:+UserG1GC
-XX:G1HeapRegionSize=size
-XX:MaxGCPauseMillis=time
  • 第一个参数:开启垃圾回收器
  • 第二个参数:设置Region的大小,必须设置为1,2,4,8
  • 第三个参数:设置暂停时间 ms

G1 垃圾回收阶段:

image-20221121103103993

第一阶段对新生代进行收集(Young Collection),第二阶段对新生代的收集同时会执行并发标记(Young Collection + Concurrent Mark),第三阶段对新生代、新生代幸存区和老年区进行混合收集(Mixed Collection),以此循环

Garbage First 将堆划分为大小相等的一个个区域,每个区域都可以作为新生代、幸存区和老年代

E:代表伊甸园区

S:代表幸存区

O:代表老年代

1. Young Collection (新生代收集)

  • 会STW,但相对于时间还是比较短的
image-20221121103621552 image-20221121103650947
  • 新生代垃圾回收会将幸存对象以复制算法复制到幸存区
image-20221121103822163
  • 新生代垃圾回收会将幸存区对象以复制算法复制到幸存区,幸存区存活的对象达到阈值以后会以复制算法复制到老年代

2. Young Collection + Concurrent Mark(新生代收集 + 并发标记)

初始标记:找到GC Root(根对象)

并发标记:和应用程序并发执行,针对区域所有的存活对象进行标记

  • 在 Young GC是回进行GC Root的初始标记

  • 老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),由下面的 JVM 参数决定

    -XX:InitiatingHeapOccupancyPercent=percent   (默认45%)
    
image-20221121104323636
  • 当 O 达到45%时就会进行并发标记

3.Mixed Collection(混合收集)

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

最终标记:在并发标记的过程中,可能会漏掉一些对象,因为并发标记的同时,其他用户线程还在工作,产生一些垃圾,所以进行最终标记,清理没有被标记的对象

  • 最终标记(Remark)会STW
  • 拷贝存活(Evacuation)会STW
-XX:MaxGCPauseMillis=ms
image-20221121104756921

过程:在进行混合回收时,新生代垃圾回收会将幸存对象以复制算法复制到幸存区,幸存区存活的对象达到阈值后以复制算法复制到老年代,老年代中根据最大暂停时时间有选择的进行回收,回收价值最高的,将老年代中存活下来的对象以复制算法重新赋值到一个新的老年代中

Full GC

SerialGC

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

ParallelGC

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

CMS

  • 新生代内存不足发生的垃圾收集-minor gc
  • 老年代内存不足,并发失败后,进行串行收集full gc

G1

  • 新生代内存不足发生的垃圾收集-minor gc
  • 老年代内存不足,当垃圾回收速度跟不上产生速度,退化为一个串行收集,开始Full GC

Young Collection 跨代调用

  • 卡表与Remembered Set
    • Remembered Set存在于E区中,用于保存新生代对应的脏卡
    • 脏卡:老年代被划分为多个区域(一个区域512K),如果该区域引用了新生代对象,则该区域就被称为脏卡
  • 在引用对象变更通过 post-write barried + dirty card queue
  • Concurrent refinement threads 更新 Remembered Set
  • 新生代回收的跨代应用(老年代引用新生代)问题
image-20221121120601696

在进行新生代回收时要找到GC Root根对象。有一部分GC Root对象是来自老年代,老年代存活的对象很多,如果遍历老年代找根对象效率非常低,采用卡表(Card Table)的技术,将老年代分成一个个Card,每个Card差不多512k, 老年代其中一个对象引用了新生代对象,那么就称这个Card为脏卡(dirty card)。

image-20221121120634666

将来进行垃圾回收时不需要找整个老年代,只需要找脏卡区就行了

Remake (重新标记)

pre-write barrier+ satb_mark_queue

在垃圾回收时,收集器处理对象的过程中

黑色:已被处理,需要保留的

灰色:正在处理中的

白色:还未处理的

image-20221121120859582

但是在并发标记的过程中,有可能A被处理了以后未引用C,但该处理过程还未结束,在处理结束之前A引用了C,这时就会用到remark

过程如下:

  • 之前C未被引用,这时A引用了C,就会给C加一个写屏障,写屏障的指令会被执行,将C放入一个队列当中,并将C的状态变为处理中状态
  • 并发标记阶段结束以后,重新标记阶段会STW,然后将方在该队列中的对象重新处理,发现有强引用引用它,就会处理它
image-20221121121239319

JDK 8u20 字符串去重

过程:

  • 将所有新分配的字符串(底层是char数组)放入一个队列
  • 当新生代回收时,G1 并发检查是否有重复的字符串
  • 如果字符串的值一样,就让他们引用同一个字符串对象
  • 注意:其与String.intern的区别
    • intern关注的是字符串对象
    • 字符串去重关注的是char数组
    • 在 JVM 内部,使用了不同的字符串标

优缺点:

  • 节省了大量内存
  • 新生代回收时间略微增加,导致略微多占用CPU

JDK 8u40 并发标记类卸载

在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不再使用,则卸载它所加载的所有类

JDK 8u60 回收巨型对象

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

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

  • 并发标记必须在堆空间占满前完成,否则就退化为 Full GC
  • JDK 9 之前需要使用 -Xx:Initiat ingHeapOccupancyPercent
  • JDK 9 可以动态调整
    • -Xx:Initiat ingHeapOccupancyPercent 用啦设置初始值
    • 进行数据采样并动态调整
    • 总会添加一个安全的空档空间

5、垃圾回收调优


查看虚拟机参数命令:

"F:\JAVA\JDK8.0\bin\java" -XX:+PrintFlagsFinal -version | findstr "GC"

可以根据参数去查询具体的信息

1. 调优领域

  • 内存
  • 锁竞争
  • CPU 占用
  • IO
  • GC

2. 确定调优目标

低延迟 / 高吞吐量?选择合适的GC

  • CMS G1 ZGC
  • ParallelGC
  • Zing

3. 最快的GC是不发生GC

首先排除减少因为自身编写的代码而引发的内存问题

  • 查看Full GC 前后的内存占用,考虑以下几个问题:

    • 数据是不是太过了?

      // 例如在 Java 程序中查询数据库数据
      resultSet rs = statement.queryExecute();
      
    • 数据表示是否台臃肿?

      • 对象图
      • 对象大小
    • 是否存在内存泄露

      static Map map = new HashMap;
      map.put();
      // 可以使用软引用、弱引用来让这些无用的缓存数据被及时回收
      

4. 新生代调优

  • 新生代的特点:

    • 所有的 new 操作分配内存都是非常廉价的
      • TLAB
    • 死亡对象回收零代价
    • 大部分对象用完即死(朝生夕死)
    • Minor GC 所用时间远小于 Full GC
  • 新生代内存越大越好吗?

    • 不是
      • 新生代内存太小:频繁触发 Minor GC,会STW,使得吞吐量下降
      • 新生代内存太大:老年代内存占比有所降低,会更频繁地触发Full GC。而且触发 Minor GC时,清理新生代锁花费的时间会更长
    • 新生代内存设置为能容纳【并发量 * (请求 - 响应)】的数据为宜

5. 幸存区调优

  • 幸存区需要能够保存:当前活跃对象 + 需要晋升的对象
  • 晋升阈值配置得当,让长时间存活的对象尽快晋升

6. 老年代调优

第三章、类加载与字节码技术


1、类文件结构


首先通过javac命令获取类的字节码文件

根据 JVM 规范类文件的格式如下:

image-20221121194808118

2、字节码指令


3、语法糖-编译器处理


所谓的语法糖 ,其实就是指 java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利

注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。另外, 编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了 几乎等价 的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。

1. 默认构造函数

2. 自动拆装箱

**3.泛型集合取值 **

**4.可变参数 **

**5.foreach **

**6.switch字符串 **

**7.switch枚举 **

**8.枚举类 **

**9.匿名内部类 **

4、类加载阶段


4.1 加载

将类的字节码载入方法区中

内部采用 C++ 的instanceKlass描述 Java 类,它的重要 field 有:

_java_mirror 即java的类镜像,例如对String来说,就是String.class,作用是把klass保留给java使用
_super 即父类
_fields 即成员变量
_methods 即方法
_constants 即类加载器
_vtable 虚方法表
_itable 接口方法表
如果这个类还有父类没有加载,先加载父类
加载链接可能是交替运行的

注:instanceKlass 这样的元数据时存储在方法区(1.8后的元空间内),但_java_mirror是存储在堆中,可以通过前面介绍的 HSDB 工具查看

4.2 链接

**1. 验证 **

验证类是否复核JVM规范,安全性检查
例如:修改HelloWorld。class的魔数,在控制台运行,报出异常

E:\git\jvm\out\production\jvm>java cn.itcast.jvm.t5.HelloWorld
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value
3405691578 in class file cn/itcast/jvm/t5/HelloWorld
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at
java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)

**2.准备 **

  • 为static 变量分配空间,设置默认值
    • static变量在JDK 7 之前存储于instanceKlass末尾(存储在方法区),从JDK 7开始,存储于_java_mirror 末尾(存储于堆中)
    • static 变量分配空间赋值是两个步骤,分配空间准备阶段完成,赋值初始化阶段完成
    • 如果 static 变量时final的基本类型,以及字符串常量,那么编译阶段就能确定了,赋值在准备阶段完成
    • 如果 static 变量时 final 的,但属于引用类型,那么赋值也会在初始化阶段完成

**3.解析 **

  • 迅即将常量池中的符号引用交换为直接引用的过程
4.3 初始化

()V 方法

初始化即调用()V,虚拟机会保证这个类的[构造方法]的线程安全

**1.发生时机 **

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没有初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName()
  • new 会导致初始化

**2.以下情况不会初始化 **

  • 访问类的 static final 静态变量(基本类型和字符串类型)
  • 类对象.class 不会触发初始化
  • 创建该类对象的数组
  • 类加载器的.loadClass方法
  • Class.forName的参数2为false时

**验证类是否被初始化,可以查看该类的静态代码块是否被执行 **

5、类加载器


5.1 基本介绍

Java 虚拟机设计团队有意把类加载阶段中的通过一个类的全限定名称来获取描述该类的二进制字节流这个动作放入到 Java 虚拟机外部区实现,以便让应用程序自己决定如何区获取所需的类。实现这个动作的代码被称为”类加载器“(ClassLoader

**类与类加载器 **

类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超类加载阶段

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确认其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。通俗一点来说:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

名称加载的类说明
Bootstrap ClassLoader(启动类加载器)JAVA_HOME/jre/lib无法直接访问
Extension ClassLoader(扩展类加载器)JAVA_HOME/jre/lib/ext上级为 Bootstrap,显示为 null
Application ClassLoader(应用程序类加载器)classpath上级为 Extension
自定义类加载器自定义上级为 Application
5.2 启动类加载器

Bootstrap ClassLoader

可以通过控制台输入指令,使得类被启动类加载

用Bootstrap 类加载器加载类:

public class Test {
    static{
        System.out.println("Hello World!");
    }
}

执行

public class Load {
    public static void main(String[] args) throws Exception{
        Class<?> clazz = Class.forName("Test");
        System.out.println(clazz.getClassLoader());
    }
}

输出

E:\>java -Xbootclasspath/a:. Load
Hello World!
null
  • -Xbootclasspath 表示设置 bootclasspath
  • 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
  • 可以用这个办法替换核心类
    • java -Xbootclasspath:
    • java -Xbootclasspath/a:<追加路径>
    • java -Xbootclasspath/p:<追加路径>
5.3 扩展类加载器

Extension ClassLoader

如果classpath和JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用扩展类加载器加载。当应用程序类加载器发现扩展类发现已经将该同名类加载过了,就不会再次加载

将代码打包放到 JAVA_HOME/jre/lib/ext 下

打包

E:\>jar -cvf my.jar Test.class
已添加清单
正在添加: Test.class(输入 = 393) (输出 = 280)(压缩了 28%)

输出

E:\>java Load
Hello World!
sun.misc.Launcher$ExtClassLoader@70dea4e
5.4 应用程序类加载器

Application ClassLoader

自己写的类都是由应用程序类加载器加载

5.5 双亲委派模式

**概念 **

指一个类收到类加载请求之后不会尝试自己加载这个类,而是把该类加载请求向上委派给其父类加载器区完成。其父类加载器在接受到该类加载器后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都会被向上委派到启动类加载器中。若父类加载器在接受到类加载请求后发现自己也无法加载该类(通常原因是该类的Class 文件在父类的类加载路径中不存在),则父类会将该信息反馈给子类并向下委托子类加载器加载该类,直到该类被成功加载,若找不到该类,则 JVM 会抛出ClassNotFond异常

即调用类加载器ClassLoader 的loadClass方法时,查找类的规则:

lodaClass 源码

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;
    }
}
5.6 线程上下文类加载器

默认使用应用程序类加载器

5.7 自定义类加载器

**使用场景 **

  • 想加载非 classpath 随意路径中的类文件
  • 通过接口来使用实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 Tomcat 容器

**步骤 **

  • 继承 ClassLoader 父类
  • 要遵从双亲委派机制,重写 findClass 方法
    • 不是重写 loadClass 方法,否则不走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法加载器类
  • 使用者调用该类加载器的 loadClass 方法
public class Load7 {
    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader();
        Class<?> c1 = classLoader. loadClass( name: "MapImpl1");
        Class<?> C2 = classLoader. loadClass( name: "MapImp11");
        System. out. println(c1 == c2);
        MyClassLoader classLoader2 = new MyClassLoader();
        Class<?> C3 = classLoader2.loadClass( name: "MapImp11");
        System. out. println(c1 == C3);
    }
}
class MyClassLoader extends ClassLoader {
   		@Override. //. name. 就是类名称
        protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path ="e:\\myclasspath\\"+name+".class" ;
        try {
            ByteArrayOutputStream OS = new ByteArray0utputStream( ); .
                Files. copy(Paths . get(path), os);
            //得到字节数组
            byte[] bytes = os. toByteArray();
            // byte[] -> *.c1ass 数组编程字节码
            return defineClass(name, bytes, of, 0,bytes.length);
        } catch (IOException e) {
            e. printStackTrace();
        }
}
5.8 破坏双亲委派机制

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2面世以前的“远古”时代
    • 建议用户重写findClass()方法,在类加载器中的loadClass()方法中也会调用该方法
  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的
    • 如果有基础类型又要调用回用户的代码,此时也会破坏双亲委派模式
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的
    • 这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等

6、运行期优化


6.1 即时编译

JVM 将执行状态分成了 5 个层次:

  • 0层:(解释器)解释执行,用解释器将字节码翻译为机器码
  • 1层:使用 C1 即时编译器编译执行(不带 profiling)
  • 2层:使用 C1 即时编译器编译执行(带基本的profiling)
  • 3层:使用 C1 即时编译器编译执行(带完全的profiling)
  • 4层:使用 C2 即时编译器编译执行

profiling(分析) 是指在运行过程中收集一些程序执行状态,例如方法调用次数,循环的回边次数

**即时编译器(JIT)与解释器的区别 **

  • 解释器
    • 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
    • 是将字节码解释为针对所有平台都通用的机器码
  • 即时编译器
    • 将一些字节码编译为机器码,并存入Code Cache(代码缓存),下次遇到相同的代码,直接执行,无需再编译
    • 根据平台特性,生成平台特定的机器码

大部分的不常用的代码,我们无需耗费时间将其编译为机器码,而是采用解释执行的方式运行

对于仅占小部分的热点代码,我们则可以将其编译为机器码,以达到理想的运行速度

执行效率上简单比较一下:Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由来),并优化这些热点代码

**逃逸分析 **

java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术

逃逸分析的 JVM 参数如下:

  • 开启逃逸分析:-XX:+DoEscapeAnalysis
  • 关闭逃逸分析:-XX:-DoEscapeAnalysis
  • 显示分析结果:-XX:+PrintEscapeAnalysis

逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数

**方法内联 **

内联函数

内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式的用内联函数的函数体来直接进行替换

JVM 内联函数

C++是否为内联函数由自己来决定,Java 由编译器决定。Java 不支持直接声明为内联函数,如果想让它内联,你只能够向编译器提出请求:关键字 final 修饰用来指明哪个函数是希望被 JVM 内联的

总的来说,一般的函数都不会被当做内联函数,只有声明了 final 后,编译器才会考虑是不是要把把你的函数变为内联函数

**字段优化 **

反射优化

第四章、内存模型


1、Java 内存模型


很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java Memory

Model(JMM)的意思。

关于它的权威解释,请参考 https://download.oracle.com/otn-pub/jcp/memory_model-1.0-pfd

spec-oth-JSpec/memory_model-1_0-pfd-spec.pdf?

AuthParam=1562811549_4d4994cbd5b59d964cd2907ea22ca08b

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

性、和原子性的规则和保障

1.1 原子性

原子性在学习线程时讲过,下面来个例子简单回顾一下:

问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

1.2 问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

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

而对应的i–也是类型:

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

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换:

image-20221122152617422

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

// 假设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

但多线程下这8行代码可能交错运行(为什么会交错运行?思考一下):

出现负数的情况:

// 假设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
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
1.3 解决方法

synchronized (同步关键字)

synchronized( 对象 ) {
要作为原子操作代码
}

​ 使用synchronized解决并发问题:

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 < 5000; j++) {
            synchronized (obj) {
                i++;
            }
        }
    });
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
                i--;
            }
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
}

如何理解呢:你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。

当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行count++ 代码。这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。当 t1 执行完 synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count-- 代码。

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

2、可见性


2.1 退不出的循环

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

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不会如预想的停下来
}

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
image-20221122153112709
  1. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
image-20221122153132185
  1. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
image-20221122153148863
2.2 解决方法

volatile(易变关键字)

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

2.3 可见性

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

上例从字节码理解是这样的:

getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false

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

// 假设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

注意

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

3、有序性


3.1 诡异的结果

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
    num = 2;
    ready = true;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

有同学这么分析

情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1

情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

但我告诉你,结果还有可能是 0 😁😁😁,信不信吧!

这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行num = 2

相信很多人已经晕了 😵😵😵

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:

借助 java 并发压测工具 jcstress https://wiki.openjdk.java.net/display/CodeTools/jcstress

mvn archetype:generate -DinteractiveMode=false -
DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-testarchetype -DgroupId=org.sample -DartifactId=test -Dversion=1.0

创建 maven 项目,提供如下测试类

@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;
    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;
    }
}

执行

mvn clean install
java -jar target/jcstress.jar

会输出我们感兴趣的结果,摘录其中一次结果:

*** INTERESTING tests
    Some interesting behaviors observed. This is for the plain curiosity.
        2 matching test results.
        [OK] test.ConcurrencyTest
        (JVM args: [-XX:-TieredCompilation])
        Observed state Occurrences Expectation Interpretation
        0 1,729 ACCEPTABLE_INTERESTING !!!!
        1 42,617,915 ACCEPTABLE ok
        4 5,146,627 ACCEPTABLE ok
        [OK] test.ConcurrencyTest
        (JVM args: [])
        Observed state Occurrences Expectation Interpretation
        0 1,652 ACCEPTABLE_INTERESTING !!!!
        1 46,460,657 ACCEPTABLE ok
        4 4,571,072 ACCEPTABLE ok

可以看到,出现结果为 0 的情况有 638 次,虽然次数相对很少,但毕竟是出现了。

3.2 解决方法

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

@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;
    }
}

结果为:

*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.
0 matching test results.
3.3 有序性理解

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

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

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

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

也可以是

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

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性,例如著名的 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;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInsatnce()才使用 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 != null7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != nullsynchronized块外),直接返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

4、CAS 与 原子类


4.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,所以线程不会陷入阻塞,这是效率提升的因素之一

  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子

import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class TestCAS {
    public static void main(String[] args) throws InterruptedException {
        DataContainer dc = new DataContainer();
        int count = 5;
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < count; i++) {
                dc.increase();
            }
        });
        t1.start();
        t1.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;
    }
}
4.2 乐观锁与悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

4.3 原子操作类

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

可以使用 AtomicInteger 改写之前的例子:

// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            i.getAndIncrement(); // 获取并且自增 i++
            // i.incrementAndGet(); // 自增并且获取 ++i
        }
    });
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            i.getAndDecrement(); // 获取并且自减 i--
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
}

5、synchronized 优化


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

5.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

5.1 锁膨胀

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

static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块
    }
}
5.3 重量级锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)

  • Java 7 之后不能控制是否开启自旋功能

5.4 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)

  • 访问对象的 hashCode 也会撤销偏向锁

  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

  • 撤销偏向和重偏向都是批量进行的,以类为单位

  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的

  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

可以参考这篇论文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

假设有两个方法同步块,利用同一个对象加锁

static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2();
    }
}
public static void method2() {
    synchronized( obj ) {
        // 同步块 B
    }
}
5.5 其他优化

减少上锁时间

同步代码块尽量短

减少锁的粒度

将一个锁拆分为多个锁提高并发度,例如:

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

锁粗化

多次循环进入同步块不如同步块内多次循环

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

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

锁消除

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

读写分离

CopyOnWriteArrayList

ConyOnWriteSet

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值