JVM笔记

一、JVM运行时内存区域
JVM运行时内存区域有五个部分组成:
虚拟机栈:线程私有,存储方法的局部变量、操作数栈、动态链接,线程每次执行一个方法都会创建一个栈帧,方法结束之后弹出栈帧。
本地方法栈:线程私有,存储本地方法的局部变量等信息。
程序计数器:线程私有,当线程数多于核心数时CPU会使用时间片来合理分配资源,程序计数器则用于记录线程接下来要执行的字节码的地址。
堆内存:线程共享,用与存储对象实例,由于JIT优化(栈上分配),所以对象也可能存虚拟机栈。
方法区:线程共享,用于加载的类信息(class文件),存储常量,静态变量,JiT即时编译代码的数据
不同JAVA版本的字符串常量池的位置:
JDK1.6:字符串常量池and运行时常量池都位于方法区,方法区的实现是永久代。
JDK1.7:字符串常量池搬到堆内存,原因是永久代的GC效率低,很多字符串存活时间不长,可以即使清除。
JDK1.8:方法区由元空间实现,并且搬到本地内存。
字符串常量池与运行时常量池的关系:
java虚拟机规范里说规定运行时常量池是一个逻辑区域,用于存储常量、符号引用、即时编译代码,字符串常量池我认为就是运行时常量池的一个逻辑子区域!
字符串常量池:它的设计遵循一种池化思想,java虚拟机规范里面希望能字符串字面量可以复用,而不是重复存储,故有了字符串常量池。它存的是字符串对象
字符串字面量的来源:
对于String s = "a",在编译期间会存储到class文件常量池,运行期间会存储到字符串常量池。(具体来说,只有当字符串常量被调用的时候才会为他在字符串常量池里创建实例) 。
调用String 类型的intern()方法运行期间手动加到字符串常量池,如果没有则创建,如果有则返回引用。
String s = "a"; VS String s1 = new String("a"):
String s="a",首先会检查字符串常量池里是否有这个对象,如果没有则创建,如果有则s为指向"a"的引用。
String s1 = new String("a"),首先一定会在堆内存创建一个String对象,对象内部以byte[]的形式存储,然后再查看常量池里是否有"a",如果有,则指向常量池中字符串常量对象内部的byte[],如果没有,则创建一个字符串常量放到字符串常量池中,在重复上述步骤。
TODO:画成流程图。
二、垃圾收集器 G1 和CMS
G1:(1)分代回收的理念,青年代用标记复制、老年代用标记整理算法,没有内存碎片。
(2)将堆内存大小划分为大小相同的区域,使得垃圾收集的工作更加灵活,更好的管理GC时间
(3)可以建立预测GC时间的模型,GC的时候可以设定在M秒内GC不能超过N秒,这种举措也是为了灵活的控制STW时间。
CMS:只回收老年代,使用标记清除算法,会产生垃圾碎片,目的也是为了减少停顿时间
三、JVM存在的意义
传统编译型语言C/C++:源代码--编译器--机器码(在硬件平台上运行)
java:源代码--编译器--字节码--JVM--机器码(动态生成)
通过JVM可以实现跨平台,我们只需要得到字节码文件,可以放到不同硬件平台的JVM翻译成对应的机器码文件运行,这就是“一次编译,到处运行”。
四、JIT优化技术:
因为,在JVM内部内置了解释器,运行的时候可以对字节码文件进行解释和翻译成机器码,然后再执行。但是解释器的执行方式是一遍翻译一遍执行,所以这样效率很低。
为此,HotSpot引入JIT技术,如果发现某段代码(热点代码)经常运行,则把它翻译成机器码存储起来。
1、首先需要进行热点检测:基于采样方式探测、基于计数器的方式。
HotSpot使用的是基于计数器模式,为每一个方法准备了:方法计数器(记录被调用次数)和回边计数器(记录方法中for、while循环次数)。
2、检测到热点代码之后根据逃逸分析进行JIT优化。
全局逃逸:超出线程或者方法的范围,可以在作用域外被访问。比如一个方法创建一个对象,然后把他作为返回值返回。一个线程创建一个对象,他就是线程运行完返回的结果,CF任务中的thenApply( )。
参数逃逸:对象被作为值传递或者引用传递,但再方法调用期间不会全局逃逸
无逃逸:对象被替换了,没有返回给外界,例如StringBuilder.toString(),返回的是新String对象
3、根据逃逸类型进行JIT优化
锁消除:编译同步块的时候,会看这个锁对象会不会全局逃逸,或者说,是不是只有一个线程访问,如果只有一个线程访问就会取消同步,也就是锁消除。
标量替换:java的原始数据类型就是标量(不能再拆分),对象就是聚合量(可以继续拆分)。如果JIT发现一个聚合量没有全局逃逸或者参数逃逸,就会把聚合量拆分成标量存储。
栈上分配:一个局部对象不一定直接分配到堆内存,JIT会分析如果局部对象没有逃逸到方法和线程外部的话,这个对象就不会在堆内存上分配,而是在线程私有的虚拟机栈上分配。
这就导致对象和数组元素并不一定分配在堆内存上,JIT会进行逃逸分析来决定分配在对还是在栈。
方法内联:对于小型的方法调用,直接把方法体放到调用方哪里

五、为什么会有线程安全问题,底层原理是什么?
多线程环境下的线程对共享变量进行读写或者写写操作会出现线程安全问题
线程安全的三大特性是原子性、可见性、有序性三大问题。
原子性:
产生的原因是,线程调度是由操作系统来决定的,并且这是一种抢占式调度,这就意味着一些操作可能还没做完就会被打断,然后让另一个线程操作。
解决方法就是加锁,加锁有乐观锁和悲观锁,例如Synchronized、Reentrantlock、CAS、原子类(AutomicInteger,底层也是CAS)。

可见性:
因为CPU多级缓存的引入,为了提高性能,线程对变量处理的时候只会与工作内存中的变量交互,不会处理内存里的变量,这就导致一个线程修改变量信息另一个线程无法及时知道。
解决办法就是对变量加volatile(悲观锁)修饰,确保每次交互都和内存中的数值交互。

有序性:
编译器和CPU使用指令重排进行性能优化,改变指令执行的顺序,只能保证单线程执行结果不变,但是不能保证多线程是安全的。
解决办法就是JMM里的happens-before,一个线程在另一个线程前执行,那么这个线程的结果对后面线程是可见的,但是happends-before也是有固定的原则的,不是什么时候都可以成立。
六、为什么局部变量没有线程安全问题
因为局部变量是存在虚拟机栈的栈帧里面的,而虚拟机栈是线程私有的,其他线程访问不到。
七、线程池有没有线程安全问题?
线程池只负责创建线程,并将任务提交给线程,然后获取任务最终的执行结果,具体线程安全问题取决于业务代码。
(1)死锁问题
(2)竞态条件(多个线程并发对变量修改可能出问题,竞态指的是竞争状态)
(3)如果变量没有synchronized、volatile等,可能指令重排带来的数据不一致问题。
happens-before的原则只能保证部分变量的内存可见性:
原则1:程序顺序原则,一个线程中的每个操作happens-before后续操作
原则2:线程解锁会happens-before后续的线程
原则3:volatile修饰的变量的写操作happens-before后续的操作。
八、synchronized和lock的区别
(1)synchronized是关键字,是JVM层的一个机制,不需要手动加锁和释放锁。
lock是concurrent包下面locks里面的一个接口,得手动加锁和释放锁(finally)
(2)synchronized是抢占式的,所以他是非公平锁。lock可以实现公平与非公平锁。
非公平锁的意义在于性能高,线程来了就先抢一把锁,抢不到再加入同步队列,这样可以减少线程上下文切换。例如在吞吐量高、追求性能的场景下使用非公平锁。
公平锁的意义在于不会饿死线程,确保资源公平分配,确保排队一定能拿到锁,不保证死锁的情况。例如在需要严格保证顺序的场景下,电商秒杀场景创建订单需要保证先来后到,所以要加锁确保严格的顺序。

(3)加锁流程:
synchronized加锁与锁升级:
对象头里主要有hashcode、GC年龄、锁标记。
锁升级流程:无锁、偏向锁、轻量级锁、重量级锁。
因为竞争激烈的时候,如果再用CAS,就会存在大量的重试和CPU资源的浪费,这个时候不如就直接把锁升级了,让操作系统介入,也就是重量级锁的阻塞。并且锁一旦升级就回不去了!
无锁(biasable):只有一个线程访问,没有并发
偏向锁(biased):第一个请求获得锁就会变成偏向锁。对象头里面那块区域存的就是线程ID、重入次数、标记。
轻量级锁(thin lock):偏向锁开始有其他线程竞争就会升级为轻量级锁。没拿到锁的线程会自旋。虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
重量级锁(fat lock):目前只有一个自旋等待的线程,如果线程自旋超过一定次数,或者又来了一个线程访问,就会升级成重量级锁。没拿到锁的线程会阻塞。锁对象的mark word中的指针不再指向线程栈中的lock record,而是指向堆中与锁对象关联的monitor对象。monitor对象包含:owner(持有锁的线程ID)、EntryList(获取锁失败而进入等待 ,BLOCKED状态)、waitSet(主动调用wait进入等待状态,WAITING和TIMED_WAITING状态)。
用户先使用CAS尝试修改monitor中owner的线程ID
修改失败后会在用户态自旋,而不是直接阻塞(代价大)
自旋失败,JVM调用操作系统指令从用户态切换到内核态,将线程标记为阻塞,将线程放入EntryList中,释放线程占用的CPU资源。
当锁释放之后,JVM会通过系统调用通知内核,内核从EntryList中选择一个线程唤醒,将其置为就绪状态,重新竞争锁。
此时互斥是由操作系统的互斥量(mutex)实现的,线程之间的调度会从用户态和内核态之间切换。
阻塞的线程就会向释放锁的这个事件上注册一个回调方法,等锁释放之后底层就会执行回调函数,唤醒阻塞的线程来抢锁,这就是一种事件驱动机制。
但是如果调用wait()方法或者调用Object.hashcode()就会在对象头里面存hashcode,直接是重量级锁,不会再变回轻量级以下的锁了。
九、垃圾回收机制
fullGC触发条件:
老年代空间不足:创建一个超过阈值的对象会直接保存到老年代,老年代内存不足直接fullgc
YongGC时空间分配担保失败:创建新对象,根据对象大小阈值,判断是放在青年代还是老年代,如果是放在青年代发现内存不够就会触发空间分配担保,如果担保失败就会fullgc。
青年代使用标记-复制算法,将Eden、Survivo1两个区的存活对象放入Survivo2区,如果Survivo2区空间不够,那么需要老年代做兜底,此时需要检查老年代可用的连续内存是否大于青年代存活对象,如果大于则担保成功,如果不足则继续判断历届晋升老年代对象平均大小,如果内存足够则继续yonggc,如果不够则直接full gc。
永久代空间不足:
手动执行system.gc()命令
哪些场景会OOM:
堆内存溢出:例如内存泄漏或者堆大小设置不当
永久代溢出:例如类加载文件过多
虚拟机栈溢出:例如递归过深

空间分配担保机制(CMS):
因为yonggc之后,一个survivor区未必能存所有的新生代存活的对象,这个时候会把一些对象放到老年代,这个时候就需要老年代内存是足够的。
所以每次yonggc之前JVM都会根据历次晋升到老年代对象的平均大小和老年代最大的连续内存做对比:
如果老年代内存还小了,则fullgc
如果老年代内存够,就去老年代
如果yonggc剩余存活对象大小小于survival区,则直接进survival区。
垃圾回收器:
青年代:串行收集器、并行收集器、并行青年代收集器
老年代:串行老年代收集器、并行老年代收集器、CMS
(Java 1.9)G1收集器(老少皆宜)
(Java 1.8)并行回收期,年轻代与老年代都用并行回收,一个是标记-复制、一个是标记-整理。

串行收集器:单线程收集,会出现STW,暂停所有用户线程,性能较差,使用标记复制、标记整理
并行收集器:多线程收集,吞吐量大,会出现STW,只是STW时间比较短。使用标记复制、标记整理
新生代用并行青年代收集器,老年代用CMS:大大减少STW时间

为什么要有STW:
用户线程不停止,会持续产生垃圾
用户线程会改变引用关系,导致漏标或者多标
如何判断有垃圾:
引用计数法:有引用+1,引用失效-1。无法处理循环依赖的问题;
可达性分析:从GCRoots出发,走过节点会形成引用链,如果一个节点没有引用链相连,则表明对象没有被引用,清除这些不可达对象。但是无法处理循环依赖的问题;
GC Roots:主要是一些必须活跃的引用(虚拟机栈or本地方法栈的引用对象、静态属性的引用对象、常量的引用对象、类加载器、异常类等等)
三色标记法:此前的方法存在:无法处理循环依赖、STW时间长两个问题。
三种状态:白色(未被标记)、灰色(扫描了对象本身,但没扫它的全部引用对象)、黑色(从根节点开始,已扫描过它全部引用对象)
初始标记:标记根节点引用对象为灰色,STW
并发标记:从灰色节点开始遍历整张图,并根节点置为黑色,然后遍历到的节点变成灰色,使用写屏障来保证并发标记的正确性,这个阶段与用户线程并行,因为为了速度块,但是可能产生漏标和多标
重新标记:纠错阶段,把灰色对象置为黑色,剩下的白色节点就是垃圾,STW
并发清除:清理白色节点,这个阶段与用户线程并行
因为它就是为了高效,所以第二阶段直接并发,第三阶段再来补漏,它只要保证不漏标就行,多标无所谓
多标:多标为黑色的,本来应该是白色的。会产生浮动垃圾,但是后续GC可以被回收
漏标:把活跃对象清除了,比较严重。
为什么会产生漏标?
CMS采用增量更新方案:记录实时变化,每次变化都会重新对节点检查
G1采用原始快照方案:基于GC开始的时候的状态做决策
G1:JDK1.9默认收集器
优点:
并发标记节省时间,不需要STW,重新标记阶段利用多核CPU的优势减少STW。
分代收集,保留分代的概念能更好管理青年代与老年代
可预测停顿时间,有一个回收时长的预测模型,我们可以设置每次GC可接受的最长时间,来减少STW。
可以在运行时动态调整堆大小
对整堆进行垃圾清理,采用标记复制和标记整理算法,使用三色标记法(原始快照防止漏标)。
垃圾回收模式:
Young GC(年轻代回收):主要针对年轻代区域的垃圾回收,包括Eden区和Survivor区。当所有Eden区使用率达到最大阀值(默认60%)或者G1计算出来的回收时间接近用户设定的最大暂停时间时,会触发一次Young GC,回收Eden区和Survivor区,复制移动到另外的Survivor幸存者(年龄+1)或Old老年代区(提前晋升的)
Mixed GC(混合回收):Mixed GC是G1垃圾回收器独有的,也称混合回收,针对年轻代和部分老年代区域的垃圾回收。当老年代的占有率达到阀值(默认45%)或年轻代被分配大对象时,会触发一次Mixed GC,回收所有年轻代和一部分老年代区(选取的策略是垃圾对象最多的老年代区域,确保释放更多内存空间,即回收价值高的),控制最大暂停时间。
Young GC:
记忆集(记录跨代引用的,老年代和青年代之间的引用关系)
卡页(所有区每个region划分成一小块,这个小块就是卡页,每个卡页都有一个全局编号)
卡表(字节数组,记录哪些卡页引用了自己region的对象,卡页对应字节数组的位置为1 则为脏卡,只需要遍历所有卡表就能形成记忆集,把记忆集对象加到GC Root里面,防止老年代引用的青年代对象被回收调)
写屏障 更新脏卡的时候使用写后屏障,这个写屏障是用户线程加的,为了防止多线程操作的并发安全问题,后台异步开一个单独的线程区把脏卡放到脏卡队列。
内存泄漏:成功分配的内存在不使用的时候不能及时释放
内存溢出:要分配内存的时候发现内存超了
十、垃圾收集器:
CMS:并发标记-清除垃圾回收器。只针对老年代。并发收集CMS器追求的是最短STW时间。垃圾收集的过程主要是三色标记法:
1、初始标记:需要STW。主要标记从GCRoots可以直达的对象。
2、并发标记:无需STW。遍历对象图,标记第一阶段相关联的对象。
3、重新标记:需要STW。修正并发标记的结果,标记并发过程中被修改或者未标记的对象。
4、并发清理:无需STW。对第三阶段的结果进行清理
缺点:
1、标记清除算法会导致垃圾碎片过多,老年代空间分配担保失败进行Full GC
2、对CPU资源敏感,并发过程需要占用线程。
3、无法清理并发清理阶段的浮动垃圾。
G1 :并发标记-复制垃圾回收器。针对整堆,将整堆划分成2048个Region。G1追求的是更短的、更可控的STW时间。
G1回收器同样参考分代回收的思想,它将整堆划分为2048个Region(4GB的区域每个Region大小为2MB),逻辑上有青年代(Survivor、Eden)、老年代(Old)、Humongous(大对象,对象超过一个Region区域的50%),一共四块区域。并且青年代区域一开始只占整堆5%,后续会根据实际情况扩大。
G1在进行垃圾回收时会参考设定的最短停顿时间与维护的优先列表,去制定一个回收计划来做到更大的回收价值。例如:此次需要进行YoungGC,一共有1000个Region,但是最短停顿时间只有200ms,查看优先列表发现只够回收800个Region,所以最终只会回收800个Region。再例如:停顿时间过短,但是YoungGC区域过大,他会优先去扩大青年代,而不是直接YoungGC,所以这里停顿时间也需要合理设置。
G1对青年代采用标记复制算法,对老年代采用标记整理算法,都不会产生垃圾碎片。
G1的GC模式有:YoungGC、MixedGC(YoungGC+部份FullGC)、FullGC。
G1的回收过程:初始标记、并发标记、重新标记、筛选清除(特点:根据回收的最大价值进行GC)

追求、分代回收+region、gc模式、回收过程、回收算法

参考https://blog.csdn.net/qq_39404258/article/details/126453136

十一、 JVM工具
jps(JDK):查看所有java进程的pid
ps(LINUX):查看所有进程的pid
jstack:(JVM):查看线程的堆栈信息,用于诊断死锁与线程阻塞。
jmap(JVM):生成堆内存的dump文件,排查内存泄漏。
jhat(JVM):将dump文件转化为html
jstat(JVM):监控虚拟机运行状态信息,例如类加载数量、堆内存信息、GC信息(GC次数与时间)、JIT编译
第三方工具:VisualVM、Arthas(Alibaba)
十二、AQS -抽象队列同步器
结构:state、CHL(同步队列)、ConditionQueue(条件队列)。
state:volatile 类型的 int 变量,充当锁是否被占用的标志位,此外还可以充当可重入锁的重入次数、CountDownLatch 计数次数、Semaphore 的许可证次数。
CHL :双向链表。负责维护抢锁失败的线程,将其加入链表尾部,每次锁释放,都会通知链表头节点(null 节点,头节点是哨兵)的下一个节点来抢锁,这叫做前驱节点驱动。因此这是一种先进先出的方式获取锁。
ConditionQueue:AQS 实现了 Condition 接口,底层维护了一个条件队列,他是一个单向链表。当线程调用 await 方法时将其加入条件队列中,如果有其他线程调用 signal/signalAll 方法,则条件队列头部节点唤醒去抢锁,如果抢到了则回到同步代码块中保持有锁的状态,如果抢不到则加入 CHL 队列阻塞等锁。
十二、Synchronized -JVM 内置锁
Synchronized 又称为 MonitorLock(监视器锁),他是基于对象头的标记来获取锁和释放锁,底层是在同步代码块前后添加内存屏障 moniterenter、moniterexit 来实现同步。JDK1.9 之后, Synchronized 锁有四个阶段:无锁、可重入锁、轻量级锁、重量级锁。
重量级锁下获取不到锁的线程会阻塞,由于 JVM 层面做不到释放 CPU 资源,充其量也只能不停的空转,这样依然会占用时间片。因此首先将线程加入对象的 entryList 中,然后切换到内核态,将线程从 CPU 的运行队列摘除,放到内核的等待队列(Linux 有一个 futex 队列),线程释放锁之后会调用 unpark()方法依次从操作系统的 futex 队列与对象的 entrylist 中拿出一个线程去抢锁(只唤醒一个,否则会出现惊群效应),抢不到锁继续阻塞。
wait 方法:前提:线程有锁并且在同步代码块中调用 wait 方法。调用后线程进入 WAITING/TIME_WAITING 状态,释放锁和 CPU 资源,从用户态切换到内核态,线程依次加入 entrylist 和内核的 futex 队列。直到被 notify /notifyAll 唤醒,从 waitSet 到 EntryList 中被 unpark 方法唤醒。操作系统底层从运行队列(一个或者多个)摘除放到 futex 队列,再放回运行队列。wait 方法唤醒之后一定是重新回到同步代码块的,所以必须要重新去抢锁,抢不到的话就继续阻塞,重新进入 EntryList 中,wait 方法被唤醒的时候是先进后出,因为是从 EntryList 尾部获取节点。

posted @ 2025-09-19 12:44  呈两面包夹芝士  阅读(11)  评论(0)    收藏  举报