JVM复习总结2024.4.18(很重要)

时间:2024-04-26 07:09:48

一、JVM类加载机制

类加载机制是指我们将类的字节码文件所包含的数据读入内存,同时我们会生成数据的访问入口的一种特殊机制。类加载的最终产品是数据访问入口。

  • 类加载机制的流程是什么?
  • 类加载器作用:①加载类;②确定类在Java虚拟机中的唯一性。
  • 类加载器的种类有哪些?双亲委派模型及其作用是什么?

双亲委派机制只是Java推荐的机制,并不是强制的机制。
我们可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。

二、JVM内存结构(运行时数据区)

堆、方法区、栈、本地方法栈、程序计数器。(前两个线程共享)

2.1 内存结构详解★★★★★

程序计数器:作用是记录下一条JVM指令的执行地址;它是唯一不会发生内存溢出的区域。

本地方法栈:执行native的方法。

栈:随着线程的创建而创建。归线程所有,栈是线程运行需要的内存空间,栈由多个栈帧组成,栈帧是每个方法运行所需要的内存。一个方法对应一个栈帧。栈帧(Frame)包含四部分内容:局部变量表、操作数栈、动态链接、返回值地址(方法出口)。每个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程。虚似机栈的生命同期和线程相同。

设置栈内存参数,如:-Xss256k

Java.lang.*Error 栈内存溢出

导致栈内存溢出的情况:栈帧过大、过多、或者第三方类库操作,都有可能造成栈内存溢出

栈帧:每个栈帧对应一个被调用的方法,可以理解为一个方法的运行空间。每个栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向运行时常量池的引用(A reference to the run-time constant pool)、方法返回地址(Return Address)和附加信息。

  • 局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中 局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。 ​
  • 操作数栈:以压栈和出栈的方式存储操作数的。 ​
  • 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。 ​
  • 方法返回地址:当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。

堆:在虚拟机启动时创建。存放对象实例,几乎所有的对象实例都足在此分配内容,是垃圾回收器管理的主要区域。(通过new关键字创建的对象,都会使用堆内存)Java对象实例以及数组都在堆上分配。

  • 堆中的对象是线程共享的,堆中的对象一般都需要考虑线程安全问题(有例外);(前面说的虚拟机栈中的局部变量只要不逃逸出方法的作用范围,都是线程私有的,都是线程安全的);

  • 垃圾回收机制;(Heap中不再被引用的对象,就会被当作垃圾进行回收,以释放堆内存)

  • 设置堆内存,如:-Xmx8m

  • java.lang.OutOfMemoryError:Java heap space 表示Java堆内存空间不足导致的内存溢出的错误

方法区:方法区域是在虚拟机启动时创建的。存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译出来的代码等数据。(JDK1.8及之后,字符串常量池放到了堆中)

方法区是规范,永久代和元空间都只是一种实现而已。
永久区:是Hotspot虚拟机内的概念,它是Hotspot虚拟机对方法区的一个具体的实现,在1.8后Hotspot将永久区移除。
元空间:上面说到,Hotspot在1.8之后移除了永久区,新的方法区实现就由元空间来负责。
​
规范不强制方法区的位置,比如Oracle的HotSpot虚拟机在JDK1.8以前,它的实现叫做永久代,永久代就是使用了堆的一部分作为方法区;但JDK1.8以后,它把永久代移除了,换了一种实现,这种实现就元空间。元空间用的就不是堆内存,而是本地内存,即操作系统的内存。
​
永久区和元空间的区别:
    永久区是占用jvm虚拟机的内存;
    元空间则是占用本地内存(操作系统的内存),也就是虚拟机之外的内存,极大的减少OOM情况的发生。 

JDK1.6中:运行时常量池(Runtime Constant Pool)是方法区的一部分。

JDK1.7中:运行时常量池放到了堆中。

JDK1.8中:运行时常量池仍在堆中,和JDK1.7最大的差别就是元数据取代了永久代。元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存

在原来的永久代划分中,永久代(方法区)用来存放类的元数据信息、静态常量以及常量池等。现在类的元信息存储在元空间(方法区)中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。

方法区内存溢出

  • 1.8 之前会导致永久代内存溢出 java.lang.OutOfMemoryError:PermGen space

    • 使用 -XX:MaxPermSize=8m 指定永久代内存大小

  • 1.8 之后会导致元空间内存溢出 java.lang.OutOfMemoryError:Metaspace

    • 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小

常量池:就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量 等信息;

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

2.1.1 线程运行诊断

案例1:CPU占用过高诊断

  1. top命令:查看哪个进程(PID)对CPU的占用过高;

  2. ps命令:进一步定位是哪个线程引起的cpu占用过高;

ps H -eo pid,tid,%cpu | grep 进程id

(将10进制的线程tid号转换为16进制,须与第3步中16进制的线程号匹配)

  3.jstack 进程id,该命令可以将问题进程中所有的线程以16进制形式列出来,进而可以定位到代码位置。

案例2:程序运行很长时间没有结果

jstack 进程id,执行该命令,排查死锁。

2.1.2 堆内存诊断

jps 工具 查看当前系统中有哪些 java 进程

jmap 工具 查看堆内存占用情况 jmap - heap 进程id

注意:它只能查询某个时刻堆内存的使用情况。如果想连续监测,需要使用下面这个工具。

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

除了监测堆内存,还可以监测CPU,线程。

jvisualvm可视化工具

堆转储 dump功能,将内存快照抓下来,再分析内存占用最大的对象。

2.1.3 常用命令或工具★★★

jps 查看java进程

jinfo 查看虚拟机配置参数信息

jstack分析死锁(jconsole可以代替)或CPU高的原因(arthas可以代替)

jmap分析内存泄漏的原因(jvisualVM可以代替)

jstat 常用语分析GC信息(jconsole、arthas都容易代替)

jconsole可视化分析内存变化、GC、线程数、死锁比较方便

参考:jvm 性能调优工具之 jinfo命令详解-****博客

【JDK工具】jinfo、jps、jstack、jstat、jmap、jconsole_jdk jstack 日志分析工具-****博客

 

2.2 常量池分类

1)静态常量池

静态常量池是相对于运行时常量池来说的,属于描述class文件结构的一部分。

字面量符号引用组成,在类被加载后会将静态常量池加载到内存中也就是运行时常量池

字面量 :文本,字符串以及Final修饰的内容

符号引用 :类,接口,方法,字段等相关的描述信息。

2)运行时常量池

当静态常量池被加载到内存后就会变成运行时常量池。也就是真正的把文件的内容落地到JVM内存了。

3)字符串常量池

设计理念:字符串作为最常用的数据类型,为减小内存的开销,专门为其开辟了一块内存区域(字符串常量池)用以存放。

JDK1.6及之前版本,字符串常量池是位于永久代(相当于现在的方法区)。

JDK1.7之后,字符串常量池位于Heap堆中

2.3 基于JDK1.8的String intern()方法解析

intern方法 1.8

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,则放入成功
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

1. 示例一

String s2 = s.intern();

当执行 String s2 = s.intern(); 时,会遵循以下逻辑:

  • 如果字符串s已经在常量池中存在,那么s2会被设置为指向常量池中已存在的相同字符串的引用。
  • 如果字符串s不在常量池中,它的内容会被复制到常量池,并且s2被设置为指向这个新添加到常量池的字符串的引用。

这段代码的主要目的是确保s2常量池中s相同内容的字符串的一个引用,这样可以优化内存使用,特别是当你有一个可变的对象但希望它在后续的操作中像一个不可变的常量一样行为时。

总结:不管放入成功还是失败,s2始终是字符串常量池中的对象。

2. 示例二 String s1 = new String("yzt");

在Java中,String s1 = new String("yzt"); 创建了两个对象:

  1. 字符串常量池中的对象:“yzt” 当创建 new String("yzt") 时,会首先查看字符串常量池中是否存在 "yzt" 这个对象。如果没有,它会被添加到池中。

  2. 堆内存中的对象:s1 使用 new 关键字创建了一个新的字符串对象,它位于堆内存中,并且与常量池中的 "yzt" 不同,尽管两者的内容相同。

3. 示例三

String s1 = new String("yzt");//创建了两个对象,一个存在于串池中,一个存在于堆内存中,尽管内容相同
String s2 = s1.intern();//串池中已经存在"yzt"对象,故放入失败,返回指向串池中字符串"yzt"的应用s2
System.out.println(s1 == s2); //false s2是串池中的对象,s1是堆内存中的对象
System.out.println(s2 == "yzt");//true
System.out.println(s1 == "yzt");//false

4. 示例四

String s = new String("a")+new String("b");
String s2 = s.intern();
System.out.println(s == "ab");//true
System.out.println(s2 == "ab");//true

执行第一行时,创建的s对象是堆中的。

执行第二行时,字符串常量池中还没有与s相同内容的字符串对象,即还没有“ab”对象,则放入成功,于是第三行输出为true,因为已经放入成功了。(注意:这里的“放入成功”是指将堆中的s对象放入到了字符串常量池中了,堆中不再有s对象了

5. 示例五

String s1 = "ab";
String s = new String("a")+new String("b");
String s2 = s.intern();
System.out.println(s == "ab");//false
System.out.println(s2 == "ab");//true

执行第三行时,字符串常量池中已经存在与s相同内容的字符串对象,即已有“ab”对象,则放入失败,于是第四行输出为false,因为放入失败了。至于s2,不管放入成功还是失败,都会是字符串常量池中的常量“ab”指向它的一个引用。

注意:JDK1.6的intern()方法与JDK1.8有不同哦。不同之处在于,1.6中放入时,如果串池中没有,则拷贝一份放入串池,相当于此时堆中和串池各有一份。

2.4 堆和非堆(方法区)

一块是非堆区,一块是堆区
堆区分为两大块,一个是Old区,一个是Young区
Young区分为两大块,一个是Survivor区(S0+S1),一块是Eden区
S0和S1一样大,也可以叫From和To

对象创建过程:一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。

什么时候会触发Full GC?

如何理解Minor/Major/Full GC

  • Minor GC:新生代
  • Major GC:老年代
  • Full GC:新生代+老年代

为什么需要Survivor区?只有Eden不行吗?

为什么需要两个Survivor区?

新生代中Eden:S1:S2为什么是8:1:1?

堆内存中都是线程共享的区域吗?

SurvivorRatio如何设置?

SurvivorRatio是JVM的一个参数,用于配置年轻代中Eden区与两个Survivor区(S0和S1)的比例。默认情况下,SurvivorRatio通常设置为8,意味着Eden区大小是单个Survivor区大小的8倍。如果你想要自定义这个比例,你可以通过以下方式设置:

-Xmn20m -XX:SurvivorRatio=6

在这个例子中,我们设置了年轻代(-Xmn)的大小为20MB,并且SurvivorRatio为6,这意味着Eden区大小是单个Survivor区大小的6倍。Eden区将是15MB (即20MB * 6 / (6 + 2)),而每个Survivor区将会是2.5MB (20MB * 1/8)。

【说明:这个是我修正过来的,GitCode上面的解释不正确!!!】

三、Java对象内存模型

3.1 对象布局

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充。

1)内存模型设计之–Class Pointer

句柄池访问对象;直接指针访问对象;

2)内存模型设计之–指针压缩

  • 为了保证CPU普通对象指针(oop)缓存;
  • 为了减少GC的发生,因为指针不压缩是8字节,这样在64位操作系统的堆上其他资源空间就少了。

3)内存模型设计之–对齐填充

对齐填充的意义是 提高CPU访问数据的效率 ,主要针对会存在该实例对象数据跨内存地址区域存储的情况。

3.2 Java对象的创建过程

Java对象的创建过程通常涉及以下几个步骤:

  1. 类加载检查:JVM检查该类是否已加载。如果未加载,它会执行类加载机制,包括加载、链接(验证、准备和解析)和初始化。

  2. 分配内存:在堆内存中为新对象分配空间。这可以通过两种主要方式完成:

    • 指针碰撞:如果堆内存已被划分为已占用和空闲部分,JVM会移动边界指针为新对象分配空间。在多线程环境中,这可能需要使用循环CAS操作来确保同步。
    • 本地线程分配缓冲(TLAB):每个线程有自己的小内存池,用于分配对象,减少线程同步的开销。
  3. 初始化默认值:所有实例字段(非静态字段)都会被赋予默认值(如0、null或false)。

  4. 字段初始化:如果类中有构造器,会按顺序执行构造器中的代码,为对象的字段赋予指定的初始值。

  5. 对象头部设置:对象头包含指向方法表的指针以及其他元数据。

  6. 返回对象引用:构造完成后,栈上的new指令得到指向新对象的引用,并将其返回给调用者。

其中,第3、4步可合并为成员变量赋值;第5步忽略;第6步可表达为调用构造方法<init>,即:成员变量顺序赋初始值,执行构造方法语句。(看马士兵书的那一版即可)

故,对象的创建过程为:1)类加载;2)分配内存;3)成员变量赋默认值;4)调用构造方法(包括两小步:成员变量顺序赋初始值,执行构造方法语句)。

3.3 其他

对象如何被确定为垃圾?

对象的四种引用?

四、垃圾回收

4.1 垃圾回收算法

  • 标记清除算法:①速度较快;②会造成内存碎片
  • 标记整理算法:①速度慢;②没有内存碎片。所有现代的标记-整理回收器均使用滑动整理,它不会改变对象的相对顺序,也就不会影响赋值器的空间局部性。
  • (标记)复制算法:①不会有内存碎片;②需要占用双倍内存空间。

分代收集理论(不是算法哦)

当前主流商业 JVM 的垃圾收集器,大多数都遵循了分代收集(Generational Collection)的理论进行设计,这里需要解释下,很多博客都会把分代收集当成一种具体的垃圾收集算法,其实并不是,分代收集只是一种理论,一套指导方针,一套符合大多数程序运行实际情况的经验法则,它建立在几个分代假说之上:

  • 弱分代假说:绝大多数对象朝生夕死;
  • 强分代假说:活得越久的对象,也就是熬过很多次垃圾回收的对象是越来越难以消亡的
  • 跨代引用假说

4.2 垃圾回收器

4.2.1 总览

蓝云飘飘:JVM学习笔记(三)垃圾回收-****博客

安全验证 - 知乎

JVM--基础--19.2--垃圾收集器--Parallel Scavenge,Parallel Old_parallel scavenge 收集器-****博客

  • 新生代垃圾回收器:Serial、ParNew、Parallel Scavenge (这三个都是复制算法)
  • 老年代垃圾回收器:CMS、Serial Old、Parallel Old (CMS是Concurrent Mark Sweep的缩写,标记清除算法;后两个采用标记整理算法)
  • 同时适用新生代老年代:G1(JDK1.7末推出的,不完善;1.8推荐使用但需要开启,1.9默认开启)

优先学习蓝云飘飘那篇博文:JVM学习笔记(三)垃圾回收-****博客

  • Serial、Serial Old:单线程;
  • ParNew:本质是Serial的多线程版本,是CMS默认的新生代垃圾收集器;
  • CMS:是Concurrent Mark Sweep的缩写,并发垃圾回收器。是一种以获取最短回收停顿时间为目标的收集器(响应时间优先,即低延时),JDK9标记为弃用,JDK14中移除;Parallel Old可作为CMS故障时的后备选择;
  • Parallel Scavenge:并行(非并发)垃圾回收器,相比ParNew更关注吞吐量优先。(吞吐量高指垃圾回收总时间占比最低) -XX:MaxGCPauseMillis控制最大的垃圾收集停顿时间,-XX:GCRatio直接设置吞吐量的大小。
  • Parallel Old:是Parallel Scavenge收集器的老年代版本,两者都是更关注吞吐量。
  • G1垃圾回收器:同时关注高吞吐量低延时,详见蓝云飘飘那篇博文。

并发:垃圾收集线程与业务线程一起执行的过程叫并发(这里的并发概念不同于JUC中的并发)。但如果硬件是单核 的,则实际效果是并发不并行。

并行:多个垃圾收集线程进行执行,会STW。 

UseSerialGC        JDK8中默认false
-XX:-UseParNewGC     JDK8中默认false
UseParallelGC    新生代,吞吐量优先    JDK8中默认true 
UseParallelOldGC   老生代,吞吐量优先    JDK8中默认true 
-XX:+UseConcMarkSweepGC  使用CMS  老年代,停顿时间优先     JDK8中默认false
-XX:+UseG1GC  使用G1GC  新生代,老年代,停顿时间优先     JDK8中默认false

  • JDK1.8默认的垃圾回收器是Parallel Scavenge、ParallelOld。

JDK11新引入的ZGC收集器

不管是物理上还是逻辑上,ZGC中已经不存在新老年代的概念了

会分为一个个page,当进行GC操作时会对page进行压缩,因此没有碎片问题

只能在64位的linux上使用,目前用得还比较少

(1)可以达到10ms以内的停顿时间要求

(2)支持TB级别的内存

(3)堆内存变大后停顿时间还是在10ms以内

4.2.2 总述

1.吞吐量和停顿时间

  • 停顿时间->垃圾收集器 进行 垃圾回收终端应用执行响应的时间

  • 吞吐量->运行用户代码时间/(运行用户代码时间+垃圾收集时间)

停顿时间越短就越适合需要和用户交互的程序,良好的响应速度能提升用户体验;
高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
小结:这两个指标也是评价垃圾回收器好处的标准。

2. 如何选择合适的垃圾收集器

https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html#sthref28

  • 优先调整堆的大小让服务器自己来选择

  • 如果内存小于100M,使用串行收集器

  • 如果是单核,并且没有停顿时间要求,使用串行或JVM自己选

  • 如果允许停顿时间超过1秒,选择并行或JVM自己选

  • 如果响应时间最重要,并且不能超过1秒,使用并发收集器

3. 是否使用G1收集器?

JDK 7开始使用,JDK 8非常成熟,JDK 9默认的垃圾收集器,适用于新老生代。

(1)50%以上的堆被存活对象占用
(2)对象分配和晋升的速度变化非常大
(3)垃圾回收时间比较长

4. G1中的RSet

全称Remembered Set,记录维护Region中对象的引用关系

试想,在G1垃圾收集器进行新生代的垃圾收集时,也就是Minor GC,假如该对象被老年代的Region中所引用,这时候新生代的该对象就不能被回收,怎么记录呢?
不妨这样,用一个类似于hash的结构,key记录region的地址,value表示引用该对象的集合,这样就能知道该对象被哪些老年代的对象所引用,从而不能回收。

5. 如何开启需要的垃圾收集器

(1)串行
    -XX:+UseSerialGC 
    -XX:+UseSerialOldGC
(2)并行(吞吐量优先):
    -XX:+UseParallelGC
    -XX:+UseParallelOldGC
(3)并发收集器(响应时间优先)
    -XX:+UseConcMarkSweepGC
    -XX:+UseG1GC

4.2.3 CMS垃圾收集器

若有必要,详见严镇涛老师讲义。

1. 算法:标记-清除算法

2. 步骤:

(1)初始标记 CMS initial mark     标记GC Roots直接关联对象,不用Tracing,速度很快
(2)并发标记 CMS concurrent mark  进行GC Roots Tracing
(3)重新标记 CMS remark           修改并发标记因用户程序变动的内容
(4)并发清除

由于整个过程中,并发标记和并发清除,收集器线程可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。

3. CMS推荐配置参数

第一种情况:8C16G左右服务器;(再大的服务器可以上G1了 没必要)

-Xmx12g -Xms12g
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=2
-XX:+UseConcMarkSweepGC
-XX:+CMSClassUnloadingEnabled
-XX:+CMSIncrementalMode
-XX:+CMSScavengeBeforeRemark
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=70
-XX:CMSFullGCsBeforeCompaction=5
-XX:MaxGCPauseMillis=100  // 按业务情况来定
-XX:+ExplicitGCInvokesConcurrent
-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
-XX:+PrintGCTimeStamps
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps

第二种情况:4C8G;(-Xmx6g -Xms6g)

-Xmx6g -Xms6g
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=1
-XX:+UseConcMarkSweepGC
-XX:+CMSClassUnloadingEnabled
-XX:+CMSIncrementalMode
-XX:+CMSScavengeBeforeRemark
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=70
-XX:CMSFullGCsBeforeCompaction=5
-XX:MaxGCPauseMillis=100  // 按业务情况来定
-XX:+ExplicitGCInvokesConcurrent
-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
-XX:+PrintGCTimeStamps
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps

4.2.4 G1垃圾收集器

官网:(好好看一下)https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection                 

使用G1收集器时,Java堆的内存布局与就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

每个Region大小都是一样的,可以是1M到32M之间的数值,但是必须保证是2的n次幂

如果对象太大,一个Region放不下[超过Region大小的50%],那么就会直接放到H中

设置Region大小:-XX:G1HeapRegionSize=<N>M

所谓Garbage-Frist,其实就是优先回收垃圾最多的Region区域

(1)分代收集(仍然保留了分代的概念)
(2)空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)
(3)可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)

工作过程可以分为如下几步:

  • 初始标记(Initial Marking)      标记以下GC Roots能够关联的对象,并且修改TAMS的值,需要暂停用户线程
  • 并发标记(Concurrent Marking)   从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行
  • 最终标记(Final Marking)        修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程
  • 筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划

G1的特点:   gaebage  First

  • 内存空间的重新定义;
  • 更短的停顿时间,要多短就多短;
  • 某种程度上去解决空间碎片。

TLAB(线程本地分配缓冲区 Thread Local Allocation Buffer)

分配空间时,为了提高JVM的运行效率,应当尽量减少临界区范围,避免全局锁。G1的通常的应用场景中,会存在大量的访问器同时执行,为减少锁冲突,JVM引入了TLAB机制。

TLAB(Thread Local Allocation Buffer)是Java虚拟机(JVM)中的一种内存分配机制。它的主要目标是提高对象分配的效率和避免全局锁的竞争。在现代的JVM实现中,如HotSpot JVM,当创建新对象时,通常会在特定线程的TLAB中进行,而不是直接在堆上分配。以下是TLAB的工作流程:

  1. 当线程需要创建新对象时,首先会尝试在自己的TLAB空间内分配内存。
  2. 如果TLAB的空间足够,对象就会被创建,并且TLAB的空间减小。
  3. 如果TLAB的空间不足,JVM会选择两种策略之一:扩展TLAB或在普通堆上分配。扩展TLAB会尝试在堆上为线程预留更多空间;而在堆上分配则意味着没有使用TLAB,可能需要获取全局锁。
  4. 随着时间的推移和内存的释放,JVM也会清理不再使用的TLAB,以优化内存利用率。

G1常用参数:

-XX: +UseG1GC 开启G1垃圾收集器

-XX: G1HeapReginSize 设置每个Region的大小,是2的幂次,1MB-32MB之间

-XX:MaxGCPauseMillis 最大停顿时间

-XX:ParallelGCThread 并行GC工作的线程数

-XX:ConcGCThreads 并发标记的线程数

-XX:InitiatingHeapOcccupancyPercent 默认45%,代表GC堆占用达到多少的时候开始垃圾收集

G1相对于CMS的的优势:

  • G1在压缩空间方面有优势。
  • G1通过将内存空间分成区域(Region)的方式避免内存碎片问题
  • Eden、Survivor、Old区不再固定,在内存使用率上来说更灵活
  • G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间,避免应用雪崩现象
  • G1在回收内存后会马上同时做合并空闲内存的工作,而CMS默认是在STW(stop the world)的时候做
  • G1会在Young GC中使用,而CMS只能在Old区使用

G1的三大回收机制:young  GC;Mixed GC;full GC

4.2.5 ZGC深入解析

略。

五、问GitCode

1. JVM 方法区

JVM 方法区,也被称为非堆(Non-Heap),是Java虚拟机的内存区域之一,主要用于存储已由虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它是线程共享的,这意味着所有线程都会访问同一个方法区。

主要特点

  1. 存储内容包括类型信息、域(Field)信息、方法(Method)信息、运行时常量池等。
  2. 方法区在JVM启动时创建,其物理内存空间可以是不连续的。
  3. 大小可固定或可扩展,但过度填充会导致内存溢出,如java.lang.OutOfMemoryError: PermGen space(JDK 7及之前)或java.lang.OutOfMemoryError: Metaspace(JDK 8及之后)。
  4. 类只能被加载一次,保证了类的全局唯一性。

发展历程

  • 在早期的HotSpot JVM中,方法区被称为永久代(Permanent Generation),使用特定的空间进行管理。
  • JDK 8开始,永久代被元空间(Metaspace)取代,元空间主要利用操作系统的native memory,允许更大的灵活性和更好的性能。

内存分配

  • 字符串常量池在JDK 7之前位于永久代,JDK 8后移到了Java堆中。
  • 静态变量存储在方法区内,对于static final类型的变量,如果其值是编译期常量,则会放入运行时常量池。

垃圾回收

  • 方法区中的一些内容,如不再使用的类、无引用的常量,会被垃圾收集器清理,这称为类卸载。
  • 类卸载条件较为苛刻,通常发生在系统资源紧张时。

相关问题

  1. 方法区和Java堆的主要区别是什么?
  2. 为什么在JDK 8中要将字符串常量池从永久代移出来?
  3. 什么是运行时常量池,它与字面量池有何联系?
  4. 系统如何判断一个类是否可以被卸载?
  5. 如何通过配置JVM参数调整方法区(元空间)的大小?

2. JDK1.7和JDK1.8对应的方法区分别存哪些内容?★★★★★

在JDK 1.7中,方法区(也称作永久代)主要存储以下内容:

  • 类的信息:如类名、超类名、接口实现等。
  • 域(Field)信息:类的成员变量及其相关信息。
  • 方法信息:类的方法签名、访问权限等。
  • 运行时常量池:存储各种常量,包括字符串常量、数字常量以及符号引用等。
  • 类装载器和权限验证相关数据。
  • 静态变量:类级别的变量。
  • 编译后的字节码指令。

而在JDK 1.8中,由于永久代被元空间(Metaspace)替代,存储的内容大致相同,但有以下几个变化:

  • 字符串常量池被移出方法区,现在存储在Java堆中。
  • 元空间主要利用操作系统内存,而不是JVM堆的一部分,因此避免了永久代可能导致的内存溢出问题。
  • 其他如类信息、字段信息、方法信息、运行时常量池(不含字符串常量)等仍然存储在元空间中。

JVM JDK1.8中静态变量保存在哪里?

在JDK 1.8中,静态变量属于类的一部分,因此它们存储在方法区域(Metaspace)中,这个区域也被称为元空间。元空间主要存储类的元数据,包括类的结构信息、方法信息以及静态变量。当类加载时,这些静态变量会被加载到元空间,并且在所有实例化对象之前就可以访问。

JVM JDK1.8对应的方法区分别存哪些内容?

在JDK 1.8中,原本的方法区(Method Area)被概念上替换为元空间(Metaspace)。以下是JDK 1.8中元空间的主要内容:

  • 类的版本信息(major version number)
  • 类名、接口名
  • 父类的全限定名
  • 接口的直接父接口
  • 常量池(包括字符串常量池,但现在字符串常量池已被移动至堆中)
  • 字段的名称、类型、访问权限
  • 方法的名称、返回类型、参数类型、访问权限、是否是桥接方法、是否是默认方法等
  • 访问标志,用于判断某个类或方法是否可被访问
  • 类装载器信息

综上,方法区存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译出来的代码等数据。只是JDK1.8及之后,字符串常量池不再放在方法区了,而是放到了堆中。

3. 字符串常量池和运行时常量池有什么区别?

字符串常量池和运行时常量池都是JVM内存模型的一部分,它们的区别在于作用范围和包含的内容:

作用范围

  • 字符串常量池:主要存储字面量字符串(比如"hello")以及用String.intern()方法添加的字符串。
  • 运行时常量池:属于方法区的一部分,存储编译期生成的各种字面量和符号引用,这些字面量包括但不限于数字、字符串、方法签名等。

引入时间

  • 字符串常量池:在程序执行前就可能存在,如静态初始化块中的字符串。
  • 运行时常量池:随着类加载到JVM中而创建,随着类的卸载而销毁。

内容类型

  • 字符串常量池:仅包含字符串。
  • 运行时常量池:包含各种字面量和符号引用,不只是字符串。

优化行为

  • 字符串常量池:通过intern()方法可以保证同一字符序列的字符串只存在于池中的一次,提高内存效率。
  • 运行时常量池:解析符号引用为直接引用,优化方法调用速度。

4. 字符串常量池在JDK 1.8中的位置变化带来什么影响?

在JDK 1.8中,字符串常量池的位置从永久代移动到了堆内存中,这一改变带来了以下影响:

  • 内存分配更加灵活:使用堆内存来存储字符串常量使得内存管理更为灵活,可以根据程序需要动态调整大小,降低了永久代溢出的风险。
  • 更少的垃圾回收压力:在元空间中,主要是存放类的元信息,这些信息在类加载时创建且生命周期与类相同,不会频繁变动。而字符串常量在堆中则会随着对象的创建和销毁进行GC,减少了对元空间的垃圾回收压力。
  • 提高性能:对于字符串拼接操作,JDK 1.8会更早地将它们放入堆中,当字符串连接的结果是常量时,可以直接指向常量池,提升了性能。
  • 优化内存使用:由于元空间主要使用的是 Native Memory,移动字符串常量池到堆中可以节省这部分系统内存资源。
  • 减少了 PermGen OutOfMemoryError 的出现:在JDK 1.7中,如果字符串常量池占用的空间过大,可能会导致永久代溢出,而在JDK 1.8中这种问题得到了缓解。

5. 为什么说JDK 1.8的字符串常量池优化了内存使用?

在JDK 1.8中,字符串常量池位于堆内存而不是元数据区域(元空间),这优化了内存使用的几个方面:

  • 按需分配:堆内存是Java应用程序的主要内存区域,其大小可以根据应用需求动态扩展。这意味着字符串常量池也可以随着程序运行按需分配空间,避免一次性分配过多内存。

  • 减少元空间压力:元空间主要用于存储类的元数据,如方法和字段的信息。将字符串常量池移出元空间减轻了这个区域的压力,减少了因元空间满而引发的内存溢出风险。

  • 更有效的垃圾回收:堆内存中的对象更容易被垃圾收集器处理。一旦不再有引用指向某个字符串实例,垃圾收集器就会回收这部分内存,提高了内存利用率。

  • 更佳的内存碎片管理:堆内存在垃圾回收过程中会进行整理,降低内存碎片,从而提高整体的内存使用效率。

  • 与对象共享:JDK 1.8中,如果两个不同的字符串变量具有相同的值,那么它们在堆中只会有一个实例,这是通过 intern() 方法实现的。这样可以有效地避免重复的对象创建,节约内存。

6. JDK JRE JVM

JDK(Java Development Kit)是用于开发和调试Java应用程序的软件套件。它包括:

  • Java编译器(javac.exe):将Java源代码(.java文件)编译成Java字节码(.class文件)。
  • Java虚拟机(JVM):执行编译后的字节码,使得Java程序能在不同平台上运行。
  • Java类库:包含各种核心API,如I/O、网络编程、集合框架等。
  • 其他开发工具:例如jar工具(打包.jar文件)、javadoc(生成API文档)、jdb(调试器)、jconsole(监控JVM性能)等。

JRE(Java Runtime Environment)是运行Java应用程序所需的最小环境,包括:

  • Java虚拟机(JVM):解释和执行字节码文件。
  • Java核心类库:支持JVM运行时所需的库文件。

JVM(Java Virtual Machine)是Java程序运行的基石,它:

  • 解释并执行.class文件中的字节码指令。
  • 提供了一个平台无关的执行环境,实现了Java的跨平台特性。
  • 管理内存,执行垃圾回收。
  • 具有本机接口(JNI),允许Java代码调用本地系统资源。

总结一下,JDK包含JRE和开发工具,JRE又包含JVM和库。因此,开发人员通常安装JDK,因为它提供了编译、运行和调试Java程序所需的一切。而仅需运行Java应用的用户则可以只安装JRE。

7. 监控JVM性能的工具有哪些?

使用以下工具可以帮助监控JVM性能:

  • JVisualVM:这是JDK自带的一个多合一工具,提供CPU、内存、线程、类加载等多方面监控,并支持JMX连接远程JVM。可以在%JAVA_HOME%\bin目录下找到。

  • JConsole:同样集成在JDK中,主要用来监视JVM的内存使用、线程状态以及MBeans等。也可在%JAVA_HOME%\bin找到。

  • VisualVM(独立版本):虽然JVisualVM已经很强大,但官方还有一个独立版本的VisualVM,提供更多的插件和功能。

  • Java Mission Control (JMC):Oracle提供的一个高级诊断和分析工具,包括飞行记录仪(Flight Recorder)和剖析器(Profiler)。可以从Oracle官网上下载。

  • MAT (Eclipse Memory Analyzer Tool)主要用于分析Java heap内存泄漏,但它也能提供一些基本的JVM性能数据。

  • JMX (Java Management Extensions):这是一个标准,通过它可以创建自己的管理工具或应用程序来监控JVM和应用。

  • Prometheus + JMX Exporter:Prometheus是一个流行的度量收集系统,通过JMX Exporter,可以暴露JVM指标以进行监控。

  • Ganglia / Nagios:这些是通用的系统监控工具,也可以用于监控JVM的各种指标。

  • Sigar:一个跨平台的系统度量库,可用于收集JVM的内存和CPU使用情况。

  • AppDynamics / New Relic:这些是商业的APM(Application Performance Monitoring)工具,提供详细的JVM监控和应用性能洞察。

最常用最好用的监控JVM性能的工具有哪些?

常用的JVM性能监控工具有以下几个:

  1. JVisualVM(包含在JDK中): 提供了丰富的功能,如内存分析、线程分析、性能采样等,且易于使用。

  2. JProfiler: 这是一个商业的高性能Java剖析工具,提供了详细的内存分析和性能优化选项。

  3. YourKit Java Profiler: 同样是商业工具,以其轻量级特性和直观的界面受到开发者的喜爱。

  4. VisualVM (旧版): 虽然已被JVisualVM取代,但有些人仍喜欢使用它,因为它支持一些老版本的JVM特性。

  5. Micrometer: 是一个度量聚合库,可与各种监控系统(如Prometheus和Graphite)集成,用于应用程序的指标收集。

  6. Ganglia 和 Nagios : 这些是通用的系统监控工具,可以监控包括JVM在内的多种服务的状态和性能。

  7. Java Mission Control (JMC): Oracle提供的高级Java诊断和分析工具,特别是针对Java飞行记录(Flight Recorder)的数据进行深入分析。

8. JVisualVM监控工具可以做哪些事情?

JVisualVM是一款强大的多合一JVM监控和分析工具,它可以做以下几件事情:

  • 监视JVM: 实时监控Java应用程序的CPU使用率、堆内存、非堆(PermGen或Metaspace)内存、线程状态以及垃圾收集器活动

  • 内存分析: 创建heap dumps以检测内存泄漏,并分析内存中对象的大小和数量。

  • 性能分析: 使用 Sampling Profiler 来确定代码中的热点,即那些占用 CPU 时间最多的部分。

  • 线程分析: 查看当前运行的线程,识别可能的死锁并提供死锁的可视化表示。

  • 类加载器查看: 显示已加载的类及其数量,帮助理解类加载行为。

  • JMX管理: 通过JMX接口连接并管理远程服务,例如Tomcat等应用服务器,进行配置更改或获取监控数据。

  • 本地和远程连接: 可以连接到本地的JVM进程,也可以连接到远程的Java应用程序。

  • Garbage Collector日志分析: 可以读取GC日志并以图表形式展示,帮助理解垃圾回收的行为。

9. 类加载机制

Java的类加载机制主要包括以下四个步骤:

  • 加载(Loading): 类加载器找到并读取类的二进制数据,这个过程可以从.class文件中加载,也可以从网络、数据库或其他数据源获取。

  • 链接(Linking):

    • 验证(Verification): 确保被加载的类符合Java语言规范,不会破坏JVM的安全性。这一阶段会检查类文件的结构、常量池、字节码等。
    • 准备(Preparation): 分配内存给类的静态变量,并初始化它们为默认值(例如,int类型的静态变量默认值为0,booleanfalse)。注意,这里并不涉及代码的执行,仅仅是为静态变量分配空间。
    • 解析(Parsing): 将符号引用转换为直接引用。这意味着将类、接口、字段和方法的引用转换成可以直接定位到内存地址的形式,以便后续的访问。此阶段不必须在类加载时完成,可在第一次使用时动态解析。
  • 初始化(Initialized): 对类的静态变量和静态代码块执行初始化操作初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

  • 使用(Using): 当类的实例被创建,或者类的方法被调用时。

类构造器<clinit>()与实例构造器<init>()

在Java中,<clinit> 和 <init> 分别代表类初始化方法和实例初始化方法,它们在不同的场景下运行:

类构造器():

  • 类构造器或者叫做静态初始化块,是用于初始化类级别的静态变量和执行静态初始化逻辑的地方。
  • 当类首次被加载或首次被使用(例如通过创建类的实例,访问类的静态成员或方法)时,<clinit> 方法会被自动调用。
  • 类构造器由所有的静态初始化语句组成,并且在同一时间线程安全地执行。
  • 程序员不能直接声明名为 clinit 的方法,而是由JVM生成并执行。

实例构造器():

  • 实例构造器也就是我们平常所说的构造函数,用于初始化新创建的对象的实例变量。
  • 每当创建一个新的对象时,对应的构造器都会被调用。
  • 可以定义多个构造器,它们的区别在于参数列表,允许有不同的初始化方式。
  • 构造器可以通过this 或 super 关键字调用其他构造器,确保初始化的连贯性。

注意:

加载阶段:查找和导入class文件

(1)通过一个类的全限定名获取定义此类的二进制字节流;(这一步需要类加载器完成)
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
(3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
        获取类的二进制字节流的阶段是我们JAVA程序员最关注的阶段,也是操控性最强的一个阶段。因为这个阶段我们可以对于我们的类加载器进行操作,比如我们想自定义类加载器进行操作用以完成加载,又或者我们想通过 JAVA Agent来完成我们的字节码增强操作。
在我们的装载阶段完成之后,这个时候在我们的内存当中,我们的运行时数据区的方法区以及堆就已经有数据了。

  • 方法区:类信息,静态变量,常量
  • 堆:代表被加载类的java.lang.Class对象

即时编译之后的热点代码并不在这个阶段进入方法区。

准备(Preparation)阶段:

  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
  • 这里不会为实例变量(也就是没加static)分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

初始化(Initialized)阶段:

思考:初始化过程什么时候会被触发执行呢?只有当对类的主动使用的时候才会导致类的初始化。

使用阶段:

1)主动引用
只有当对类的主动使用的时候才会导致类的初始化,类的主动使用有六种:

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如 Class.forName(“com.carl.Test”) )
  • 初始化某个类的子类,则其父类也会被初始化
  • Java虚拟机启动时被标明为启动类的类(JvmCaseApplication ),直接使用 java.exe 命令来运行某个主类

2)被动引用

  • 引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。
  • 定义类数组,不会引起类的初始化。
  • 引用类的static final常量,不会引起类的初始化(如果只有static修饰,还是会引起该类初始化的)。

卸载阶段:
在类使用完之后,如果满足下面的情况,类就会被卸载:

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

        Java虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的Class对象,因此这些Class对象始终是可触及的。
        如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。但是一般情况下启动类加载器加载的类不会被卸载,而我们的其他两种基础类型的类加载器只有在极少数情况下才会被卸载。

10. 类加载器&双亲委派

在Java中,有三种主要的类加载器类型:

  1. bootstrap class loader(引导类加载器):它是JVM的一部分,负责加载Java的核心库,如rt.jar,它位于JAVA_HOME/jre/lib目录下。

  2. extension class loader(扩展类加载器):由sun.misc.Launcher$ExtensionClassLoader实现,用于加载Java的扩展库,这些库位于JAVA_HOME/jre/lib/ext目录或系统属性java.ext.dirs指定的路径下。

  3. application class loader(应用程序类加载器):也称为系统类加载器,由sun.misc.Launcher$AppClassLoader实现,加载应用类路径(classpath)中指定的类。应用类路径可以通过环境变量CLASSPATH或者-cp命令行选项设置。

类加载器之间的关系形成了一个层次结构,其中父类加载器先尝试加载类,如果找不到,才会委托给子类加载器去加载。这种机制允许我们自定义类加载器来加载特定位置的类。

11. 双亲委派模型是什么?有何作用?

双亲委派模型是Java虚拟机(JVM)中类加载机制的一种方式。它的核心思想是,当一个类加载器接收到类加载请求时,不会立即加载,而是将这个请求委托给其父类加载器去完成,依次向上,直到 bootstrap class loader。如果父类加载器能够加载该类,则返回;如果都不能加载,那么再由最初的类加载器进行加载。

这种模型的作用主要包括:

  • 保持Java核心API的一致性:核心类库由启动类加载器加载,确保所有应用都使用同一个核心库的版本,避免出现不同应用因为加载不同版本API而引发的冲突。

  • 隔离加载:不同类加载器加载不同的类,有助于防止类的污染,比如两个不同应用都有同名但其实不相同的类,通过类加载器可以区分它们。

  • 安全性:防止恶意代码替代系统类,因为系统类总是由启动类加载器加载,不允许其他类加载器插手。

  • 模块化:有利于第三方库的加载和管理,各自使用各自的类加载器,各负其责。

13. JVM执行引擎

JVM采取的是混合模式,也就是解释+编译的方式,对于大部分不常用的代码,不需要浪费时间将其编译成机器码,只需要用到的时候再以解释的方式运行;对于小部分的热点代码,可以采取即时编译的方式,追求更高的运行效率。

JVM(Java Virtual Machine)的执行引擎是负责运行Java字节码的组件。它主要包括以下几个关键部分:

  1. 解释器:解释器将字节码转换为机器指令并在CPU上执行。对于每次方法调用,解释器都会逐条解析字节码并执行。

  2. JIT编译器(Just-In-Time Compiler):为了提高性能,JVM可以在运行过程中将热点代码(频繁执行的部分)编译成本地机器代码。这种方式结合了解释器的快速启动和编译器的高效运行。

  3. 运行时数据区:这是JVM内存模型的一部分,包括程序计数器、虚拟机栈、本地方法栈、堆和方法区等区域,它们各自存储不同类型的运行时数据。

  4. 优化技术:执行引擎还采用了各种优化技术,例如动态类型推测、死代码消除、分支预测等,以提升程序的运行效率。

  5. 垃圾收集器:负责自动管理堆内存,识别不再使用的对象并回收其占用的内存。

  6. 同步机制:提供线程同步和互斥的原语,确保多线程环境下的正确性。

JIT编译器的作用是什么?

JIT编译器(Just-In-Time Compiler)的主要作用是在Java应用程序运行期间,将性能关键的部分字节码编译成本机机器代码,以便直接在硬件上更快地执行。这样做的好处包括:

  1. 提高性能:与传统的解释执行相比,JIT编译后的代码通常运行速度更快,因为它是针对特定平台优化的机器代码。
  2. 动态优化:JIT可以根据运行时的数据进行优化,例如识别哪些方法经常被调用,然后优先编译这些热点方法。
  3. 晚绑定:JIT可以延迟某些功能的具体实现直到运行时,从而允许更灵活的代码优化和适应性。

18. java 内存模型★★★

Java内存模型(JMM, Java Memory Model)定义了线程如何访问共享变量以及如何保证线程之间的通信一致性。它规定了在多线程环境下,如何处理共享变量的读写操作,以确保并发编程的正确性。以下是JMM的关键概念:

  • 主内存(Main Memory):所有线程共享的区域,包含所有共享变量。但不直接访问它。

  • 工作