JVM大全——面试必考,学了就比别人强很多

JVM大全——面试必考,学了就比别人强很多

内存结构

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. 方法区

1 程序计数器

定义
Program Counter Register 程序计数器(寄存器)的作用,是记住下一条jvm指令的执行地址。
特点
是线程私有的。
不会存在内存溢出。

2 虚拟机栈

虚拟机栈是线程私有。

定义
Java Virtual Machine Stacks (Java 虚拟机栈)每个线程运行时所需要的内存,称为虚拟机栈。

每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
栈帧是指每个方法运行时所需要的内存(参数,局部变量,返回地址等)。
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

-Xss去改变栈空间大小。

问题辨析

  1. 垃圾回收是否涉及栈内存? 不涉及
  2. 栈内存分配越大越好吗? 并不是,因为总内存是固定的,如果栈内存分配大了,就会导致线程数变少。
  3. 方法内的局部变量是否线程安全?
    如果方法内局部变量没有逃离方法的作用访问,它是线程安全的。
    如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全。

栈内存溢出
栈帧过多导致栈内存溢出。比如我们用递归调用时,没有设置结束语句,就会导致java.stackOverflowError 。

栈帧过大导致栈内存溢出。(这种情况很少见)

线程运行诊断
案例1:cpu占用过高
解决办法:
用top定位哪个进程对cpu的占用过高:
ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)。
jstack 进程id:
可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号。
案例2:程序运行很长时间没有结果
可能是代码之间发生了死锁(deadlock)。

3. 本地方法栈(Native Method Stacks)

java在调用本地方法时所处的内存空间(例如Object的一些方法)。
本地方法栈是线程私有。

4. 堆(Heap)

定义
通过 new 关键字,创建对象都会使用堆内存。
特点
它是线程共享的,所以堆中对象都需要考虑线程安全的问题。
有垃圾回收机制。

堆内存溢出(OutOfMemoryError:java heap space)
堆内存溢出一般情况下是新创建的对象不断的去调用原来的对象,导致无法进行垃圾回收。
-Xmx 去改变堆空间大小。
关于堆中的新生代和老年代会在以后的文章中专门讲解。
堆内存诊断

在Java下点击Terminal就可以使用jps等工具。

  1. jps 工具
    查看当前系统中有哪些 java 进程
  2. jmap 工具
    查看堆内存占用情况 jmap - heap 进程id
  3. jconsole 工具
    图形界面的,多功能的监测工具,可以连续监测。

案例:
垃圾回收后,内存占用仍然很高。

因为代码实际情况,然后我们去进行查看到底是什么原因这时候用到了我们的jvisualvm工具中的堆Dump,就可以去进行查看,到底是哪个问题导致我们垃圾回收后,内存占用仍然很高。

5. 方法区(OutOfMemoryError)

定义
方法区存储信息主要:类信息,类加载器(加载类的二进制字节码),域信息,方法信息,常量,静态变量,即时编译器编译后的代码缓存。
特点
1.方法区在jvm启动的时候被创建。
2.方法区也是线程共享的

组成
1.6和1.8版本的变化如图,在这幅图中应该更能清楚方法区的内存结构

在这里插入图片描述方法区内存溢出
1.8 以前会导致永久代内存溢出

演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
-XX:MaxMetaspaceSize=8m

1.8 之后会导致元空间内存溢出

演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
-XX:MaxMetaspaceSize=8m

场景
spring
mybatis

在使用这两个框架时,可能需要调用大量的类,所以很容易导致永久代的溢出,但是在1.8之后元空间使用的是系统内存,就不会容易导致内存溢出了。而且垃圾回收也有区别,在以后的文章中会讲解到。
常量池
我们的编程语言最后都会转变为计算机能读懂的二进制字节码,而二进制字节码中主要包括类基本信息,常量池,类方法定义。
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。
常量池是 *.class 文件中的。
作用
一个java源文件的类,接口,编译后产生一个字节码文件。而java中字节码需要数据支持,通常这种数据会很大以至于不能直接存储在字节码里,于是就换一种方式,存储到常量池里,以便于虚拟机查找。

运行时常量池

运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。

JVM——String Table字符串常量池

String table又称为String pool,字符串常量池,其存在于堆中(jdk1.7之后改的)。最重要的一点,String table中存储的并不是String类型的对象,存储的而是指向String对象的索引,真实对象还是存储在堆中。

先看几道经典面试题:

String s1 = "a";        //在常量池中
String s2 = "b";        //在常量池中
String s3 = "a" + "b";  //在常量池中
String s4 = s1 + s2;    //字符串变量拼接的原理是 StringBuilder (jdk1.8)(相当于在堆中新建了一个对象)
String s5 = "ab";       //在常量池中直接查找"ab".字符串常量拼接的原理是编译期优化
String s6 = s4.intern();  // intern 方法,主动将串池中还没有的字符串对象放入串池。
// 问
System.out.println(s3 == s4);  
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");   //在堆中创建" c", "d" ,"cd"
String x1 = "cd"; //在串池中创建"cd".
x2.intern();      //主动将串池中还没有的字符串对象放入串池,如果有则并不会放入,如果没有则放入串池。两种情况都会把串池中的对象返回。
// 问,
System.out.println(x1 == x2);
如果调换了【最后两行代码】的位置呢

StringTable 特性

1.String Table 是哈希表结构,不能扩容。
2.常量池中的字符串仅是符号,第一次用到时才变为对象。
3.利用串池的机制,来避免重复创建字符串对象。
4.字符串变量拼接的原理是 StringBuilder (1.8)(相当于在堆中新建了一个对象)。
5.字符串常量拼接的原理是编译期优化。
6.可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池。
7.jdk1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池。(原来堆中的就没有了)两种情况都会把串池中的对象返回。
8.jdk1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回。

输出的结果应该为:

false
true
ture
false
ture

把这个面试题读懂之后,基本上StringTable的基础问题都可以自己解决了。

StringTable的垃圾回收及调优

字符串存在于堆内存中,只有当内存紧张时,才会触发垃圾回收。

(1) 它的底层由于是哈希表,我们可以设置-XX:StringTableSize=桶个数,一般大小设置大一点,可以减少哈希冲突,使StringTable的性能提高。
(2)考虑将字符串是否入池。例如存储地址,那么有可能不同的用户有相同的地址,这时我们就要加以区分,所以采用字符串的 intern()方法,减少字符串对于内存的占用。

直接内存

定义(Direct Memory)

1.常见于 NIO 操作时,用于数据缓冲区。
2.分配回收成本较高,但读写性能高。
3.不受 JVM 内存回收管理。
4.本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制。
5.配置虚拟机参数时,不要忽略直接内存,防止出现OutOfMemoryError异常。
在这里插入图片描述

在这里插入图片描述
从这两张图能明显看出直接内存的重要性,避免了原来的两次缓冲,节约了内存空间,缩短了运行时间。

直接内存使用场景

1.有很多很大的数据需要存储,它的生命周期很长。
2.适合频繁的IO操作,例如网络并发场景。

分配和回收原理

使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法。

ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调
用 freeMemory 来释放直接内存。

垃圾回收

  1. 如何判断对象可以回收
  2. 垃圾回收算法
  3. 分代垃圾回收

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

1.1 引用计数法

在引用计数法中,假设堆中每个对象都有一个引用计数器,初始值为1。每当有一个地方引用它时,计数器的值就加 1。反之,当引用失效时,则之前被引用的对象的计数器的值就减 1。而那些引用计数为 0 的对象,就可以称之为垃圾,可以被收集。

该方法有一个弊端就是循环引用,如图。所以java不采用这种方法。
在这里插入图片描述

1.2 可达性分析算法

Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。
扫描堆中的对象,看是否能够沿着 GC Root(根)对象 为起点的引用链找到该对象,找不到,表示可以回收。

在JVM中以下的对象可以作为根对象:

1.虚拟机栈(栈帧中的本地变量表)中引用的对象
2.方法区中的常量引用的对象
3.方法区中的类静态属性引用的对象
4.本地方法栈中 Native 方法的引用对象
5.活跃线程(已启动且未停止的 Java 线程)

在上面的介绍中,我们多次提到了“引用”这个概念,在此我们不妨多了解一些引用的知识,在 Java 中有五种引用类型,分别为:
1. 强引用

如Object obj = new Object(),这类引用是 Java 程序中最普遍的。
只有所有 GC Roots 对象都不通过强引用引用该对象,该对象才能被垃圾回收。
2. 软引用(SoftReference)

它用来描述一些可能还有用,但并非必须的对象。
仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象。
可以配合引用队列来释放软引用自身。
3. 弱引用(WeakReference)

它也是用来描述非必须对象的,但它的强度比软引用更弱些。
仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。
可以配合引用队列来释放弱引用自身
4. 虚引用(PhantomReference)

必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队。
由 Reference Handler 线程调用虚引用相关方法释放直接内存。
5. 终结器引用(FinalReference)

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize方法,第二次 GC 时才能回收被引用对象。

2. 垃圾回收算法

2.1 标记清除

优点:速度较快。
缺点:标记清除后会产生大量的内存碎片,虽然空闲区域的大小是足够的,但是是不连续的。可能会导致没有一个单一区域能够满足下一次分配所需的大小。
在这里插入图片描述
2.2 标记整理

优点:经过整理之后,不会再有内存碎片的问题了。
缺点:GC 暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。GC速度慢。
在这里插入图片描述
2.3 复制

定义:它将内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面,然后再把已使用过的内存空间一次清理掉,最后在调换两个内存的位置。

优点:标记阶段和复制阶段可以同时进行;每次只对一块内存进行回收,运行高效;只需移动栈顶指针,按顺序分配内存即可,实现简单;不会有内存碎片。
缺点:需要占用双倍内存空间。
在这里插入图片描述

3. 分代垃圾回收

在这里插入图片描述
分代回收:在堆内存中,长时间使用的对象会放在老年代中,而那些用完就不要的对象放在新生代中。这样的话可以针对不同的生命周期的对象采用不同的垃圾回收算法进行分代回收,回收的效率更高。

特点:
1.对象首先分配在伊甸园区域。
2.当新生代空间不足时,触发 Minor GC,伊甸园和 From 存活的对象使用 复制算法 复制到 To 中,然后存活的对象年龄加 1并且交换 From ,To。
3.Minor GC 会引发 stop the world,(时间较短 )暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
4.当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)。
当老年代空间不足,会先尝试触发 Minor GC,如果之后空间仍不足,那么触发 Full GC。stop the world的时间更长。
5.当内存不够时,直接会放入老年代中,不用达到阈值。
6.一个线程内出现OutOfMemory,不会导致你整个java进程的结束。

垃圾回收器

1.1 串行类垃圾回收器(Serial)

特点:
1.单线程
2.堆内存较小,适合个人电脑

使用方法:-XX:+UseSerialGC = Serial + SerialOld

Serial:是指在新生代进行垃圾回收,采用的是复制算法;
SerialOld:是在老年代进行垃圾回收,采用的标记-整理算法;
在这里插入图片描述
当串行垃圾回收器发生时,所有的线程都要到达安全点位置,避免线程中的对象地址发生改变,程序紊乱。然后由于是单线程进行垃圾回收,其他的线程不能运行,所以发生堵塞,等到垃圾回收后,才能开始运行。

1.2吞吐量优先类垃圾回收器(Parall 并行)

特点
1.多线程
2.堆内存较大,多核 cpu
3.让单位时间内,STW (stop the world)的时间最短
比如在一个小时内发生了两次GC,每次的时间是0.2s,总共是0.4s。垃圾回收时间占比最低,这样就称吞吐量高。

使用方法
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
ParallelGC:(并行垃圾回收器)是指在新生代进行垃圾回收,采用的是复制算法;
ParallelOldGC:是在老年代进行垃圾回收,采用的标记-整理算法;

在通过以下方法来设置一些参数:
-XX:GCTimeRatio=ratio:期望的 GC 时间占总时间的比例,用来控制吞吐量。一般是20%。

-XX:MaxGCPauseMillis=ms:期望收集时间上限,用来控制垃圾回收对应用程序停顿的影响。一般是200ms。

-XX:ParallelGCThreads=n:控制ParallelGC工作是的线程数。

-XX:UseAdaptiveSizePolicy:自动调整新生代里面的各个区的大小比例。

但要注意停顿时间与吞吐量这两个目标是相悖的,降低停顿时间的同时也会引起吞吐量的降低。因此需要将目标控制在一个合适的范围内。
在这里插入图片描述

1.3响应时间优先类垃圾回收器(CMS)

特点
1.多线程
2.堆内存较大,多核 cpu
3.尽可能让单次 STW (stop the world)的时间最短
比如说在一个小时内发生了5次GC,每次的时间是0.1s,总时间是0.5s 。这就是响应时间优先。

使用方法:

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
CMS垃圾回收器只会发生在老年代中。在新生代中用ParNewGC,也就是一个并行的垃圾回收器。而后面的SerialOldGC是在并发失败(下面会讲到什么导致并发失败)后用到的。

在这里插入图片描述
CMS 垃圾回收器,采用“标记-清除”算法,它的运作过程分为 4 个步骤:

1.初始标记
2.并发标记
3.重新标记
4.并发清除

其中,由图像就能看出来初始标记、重新标记这两个步骤仍然需要 Stop-the-world。

  1. 初始标记仅仅只是标记一下GC Roots(根对象),速度很快。

  2. 并发标记阶段就是找到根对象引用的对象。

  3. 重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

  4. 并发清除是指只有一个线程在进行垃圾回收,其他的线程继续工作,但是这就导致在我并发清除的时候其他线程会产生一些垃圾,而无法清理,就出现了一些浮动垃圾。而当我们的浮动垃圾过多时,导致我们的内存不足而引发并发失败,此时CMS垃圾回收器就无法进行下去了,就采用我们之前讲到的SerialOld垃圾回收器。一旦发生并发失败,垃圾回收的时间就会增加很多。

CMS 垃圾回收器的优点:并发收集,低停顿。

1.5 G1 垃圾回收器

适用场景:
1.同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是 200 ms。
2.会将堆划分为多个大小相等的 Region。
整体上是 标记+整理 算法,两个区域之间是 复制 算法。这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。

相关 JVM 参数:
-XX:+UseG1GC
-XX:G1HeapRegionSize=size
-XX:MaxGCPauseMillis=time

G1的收集模式可分为三种:
在这里插入图片描述
Young GC:在分配一般对象(非巨型对象)时,当所有 Eden 区域使用达到最大阀值并且无法申请足够内存时,会触发一次 YoungGC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。会 STW。
**YoungGC+并发标记:**在 Young GC 时会进行 GC Root 的初始标记
老年代占用堆空间比例达到阈值时,进行并发标记(不会 STW)。
Mixed GC:当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个新生代,还会回收一部分的老年代,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old 区域进行收集,从而可以对垃圾回收的耗时时间进行控制。G1 没有 Full GC概念。最终标记会 STW,拷贝存活也会 STW。

整个G1的流程大致为初始标记,并发标记,最终标记,复制清除。

卡表:在我们新生代收集的过程中可能遇到老年代引用新生代的问题,如果我们一个一个的去遍历老年代,未免造成的时间太长了,在这就引入了卡表的概念。

在这里插入图片描述
图中右侧的就是我们的卡表,如果老年代中引用了新生代的对象那么就认为这张卡是脏的。

在进行 Young GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Young GC 的GC Roots里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。卡表能用于减少老年代的全堆空间扫描,这能很大的提升 GC 效率。

巨型对象:当一个对象大于 region 的一半时,称之为巨型对象。
特点:
1.G1 不会对巨型对象进行拷贝
2.回收时被优先考虑
3.G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉

相关 VM 参数
堆初始大小 :-Xms
堆最大大小: -Xmx 或 -XX:MaxHeapSize=size
新生代大小 :-Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例 :-XX:SurvivorRatio=ratio
晋升阈值 :-XX:MaxTenuringThreshold=threshold
晋升详情: -XX:+PrintTenuringDistribution
GC详情 :-XX:+PrintGCDetails -verbose:gc
FullGC 前 MinorGC :-XX:+ScavengeBeforeFullGC

JVM——GC调优

预备知识

需要掌握 GC 相关的 VM 参数,会基本的空间调整,这是最基本的东西。JVM调优是一个不断调整的过程,不能指望着一蹴而就。要不断调整相关参数,观察结果进行对比分析。还有就是,不同的垃圾收集器的JVM参数是不一样的,所以具体的GC调优要根据不同的收集器做调整。推荐看下Java关于收集器调优的官方文档:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html,

最快的 GC答案是不发生 GC。
查看 FullGC 前后的内存占用,考虑下面几个问题
1.数据是不是太多?

2.数据表示是否太臃肿?

3.是否存在内存泄漏?

新生代调优

新生代的特点:
所有的 new 操作的内存分配非常廉价
死亡对象的回收代价是零
大部分对象用过即死
Minor GC 的时间远远低于 Full GC

新生代越大越好吗?
答案是否定的,总内存是一定的,新生代大了,老年代就会小,就会发生Full GC 时间更长。

调优特点:
新生代能容纳所有【并发量 * (请求-响应)】的数据。
幸存区大到能保留【当前活跃对象+需要晋升对象】。
新生代晋升阈值配置得当,让长时间存活对象尽快晋升。

-XX:MaxTenuringThreshold=threshold(最大晋升阈值)
-XX:+PrintTenuringDistribution(打印幸存区不同年龄对象的详细信息)
例如:
Desired survivor size 48286924 bytes, new threshold 10 (max 10)

  • age 1: 28992024 bytes, 28992024 total :年龄为1 的占用内存
  • age 2: 1366864 bytes, 30358888 total :年龄为2的和年龄为1的占用内存
  • age 3: 1425912 bytes, 31784800 total:…

老年代调优

以 CMS 为例:
1.CMS 的老年代内存越大越好。因为如果当你Full GC 发生后,因为在CMS中垃圾回收是并发的,所以会产生浮动垃圾,当浮动垃圾特别多的时候就不会发生CMS Full GC,就会变成SerialOld GC 这样就会导致时间变得很长。
2.先尝试不做调优,如果没有 Full GC 那么已经内存可以满足,否则先尝试调优新生代。
3.观察发生 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
-XX:CMSInitiatingOccupancyFraction=percent (当老年代的空间占用占老年代总内存的多少时,就发生Full GC)

调优案例

案例1 Full GC 和 Minor GC频繁

Minor GC频繁说明内存空间紧张,新生代中内存紧张后,会把对象晋升老年代的阈值降低,这样就会导致老年代中存在生命周期很短的对象,触发了老年代的Full GC。
增大新生代内存空间大小,使晋升阈值增大,这样老年代中对象就会变少,Full GC就不会频繁。

案例2 请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)

先查看GC日志,看看那个时间较长。发现重新标记时间较长,因为重新标记是需要扫描整个堆内存(新生代和老年代),可以在重新标记之前,在新生代发生一次垃圾回收,这样重新标记时查找的对象就会变少。

案例3 老年代内存充裕情况下,发生 Full GC (CMS jdk1.7)

因为在1.7是永久代,内存不足会导致Full GC 。解决办法:增大元空间的初始值和最大值。
而在1.8是元空间,是由系统直接管理,内存很充裕。

JMM内存模型

1. java 内存模型

很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java MemoryModel(JMM)的意思。
关于它的权威解释,请参考 :

https://download.oracle.com/otn-pub/jcp/memory_model-1.0-pfdspec-oth-JSpec/memory_model-1_0-pfd-spec.pdf?
AuthParam=1562811549_4d4994cbd5b59d964cd2907ea22ca08b

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

1.1 原子性

定义:指一个操作是不可中断的,即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

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

答案是不一定的,以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。
如果是单线程,程序顺序执行,那一定是没问题的。
但如果在多线程中,cpu中的时间片不一定分到哪个线程中了,所以就会出现负数和正数的情况。

解决办法
用 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();
SySystem.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 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

1.2.可见性

定义:指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。

举个例子看一下
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不会如预想的停下来
}

因为 t 线程如果要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。然后你在改写run的值,就对t线程不起作用了。

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

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

1.3 有序性

定义:对于一个线程的而言,我们总是习惯地认为代码的执行时从先往后,依次执行的。这样的理解也不能说完全错误,因为就一个线程而言,确实会这样。但是在多线程时,程序的执行可能就会出现乱序。具体的感觉就是:写在前面的代码,会在后面执行。

指令重排:有序性问题的原因是因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。

解决办法
volatile 修饰的变量,可以禁用指令重排。

举个例子:

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

输出的结果可能是 0,1,4

只需要将boolean ready = false;变成volatile boolean ready = false;
这样结果就是1,4 不会出现0这种可能了。

2. happens-before规则

定义:happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,如果抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

2.监视器锁(同步)规则:对于一个监视器的解锁,happens-before于随后对这个监视器的加锁。

3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。

4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。

5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。

6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。

8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

3.CAS 与 原子类

定义:CAS 即 Compare and Swap ,它体现的一种乐观锁的思想。

比如多个线程要对一个共享的整型变量执行 +1 操作:

// 需要不断尝试
while(true) {
int 旧值 = 共享变量 ; // 比如拿到了当前值 0
int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
/*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if( compareAndSwap ( 旧值, 结果 )) {
// 成功,退出循环
}
}

特点
1.获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。

2.结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

3.因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。

4.CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令。

乐观锁
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
悲观锁
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

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

synchronized 优化

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

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
在这里插入图片描述

2.锁膨胀

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

3. 重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
如图是自旋成功的例子:
在这里插入图片描述
如图是自旋失败的例子:
在这里插入图片描述

4 偏向锁

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

特点
1.撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)。
2.访问对象的 hashCode 也会撤销偏向锁。
3.如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。
4.撤销偏向和重偏向都是批量进行的,以类为单位。
5.如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的。
可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁。

5. 其它优化

  1. 减少上锁时间
    同步代码块中尽量短。

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

  3. 锁粗化
    多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作。粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

new StringBuffer().append("a").append("b").append("c");
  1. 锁消除
    JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
  2. 读写分离
    CopyOnWriteArrayList ConyOnWriteSet

垃圾回收机制的补充说明

  • 垃圾回收机制关键点

  • 垃圾回收机制只回收JVM堆内存里的对象空间。

  • 对其他物理连接,比如数据库连接、输入流输出流、Socket连接无能为力

  • 现在的JVM有多种垃圾回收实现算法,表现各异。

  • 垃圾回收发生具有不可预知性,程序无法精确控制垃圾回收机制执行。

  • 可以将对象的引用变量设置为null,暗示垃圾回收机制可以回收该对象。

  • 程序员可以通过System.gc()或者Runtime.getRuntime().gc()来通知系统进行垃圾回收,会有一些效果,但是系统是否进行垃圾回收依然不确定。

  • 垃圾回收机制回收任何对象之前,总会先调用它的finalize方法(如果覆盖该方法,让一个新的引用变量重新引用该对象,则会重新激活对象)。

  • 永远不要主动调用某个对象的finalize方法,应该交给垃圾回收机制调用。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

真真最可爱

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值