1:JVM

1-1:JVM1.8 新特性

在 1.7 之前在 (JDK1.2 ~ JDK6) 的实现中,HotSpot 使用永久代实现方法区,而在 1.8 之后方法区被彻底移除了,而使用元空间取而代之,方法区位于堆内存中,而元空间使用的是本地内存。元空间存储的是类的元数据,常量池还是在堆内存中。

1-2:JDK1.8 默认垃圾回收器

使用 java -XX:+PrintCommandLineFlags -version 命令查看

1
2
3
4
-XX:InitialHeapSize=266668608 -XX:MaxHeapSize=4266697728 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_201"
Java(TM) SE Runtime Environment (build 1.8.0_201-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode)

UseParallelGC : Parallel Scavenge(新生代)+ Parallel Old(老年代)

2:类加载

2-1:类的生命周期

生命周期:加载-连接-初始化-使用-卸载

2-2:类的加载过程

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

2-3:类加载机制

类加载时主要完成 3 件事:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

类的加载指的是将 class 字节码文件加载到内存中,并将这些数据转换成方法区中的运行时数据(静态变量、静态代码块、常量池等),在堆中生成一个 Class 类对象代表这个类(反射原理),作为方法区类数据的访问入口。

2-3-1:符号引用与直接引用

在 JVM 中类加载过程中,在解析阶段,Java 虚拟机会把类的二级制数据中的符号引用替换为直接引用。

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。
直接引用:直接引用可以是
(1)直接指向目标的指针(比如,指向“类型”【Class 对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄

2-4:类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,负责加载 %JAVA_HOME%/lib 目录下的 jar 包和类或者或被 -Xbootclasspath 参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  3. AppClassLoader(应用程序类加载器) :面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

2-4-1:类加载器之间的关系

这三种类加载器存在父子关系,App ClassLoader 的父类加载器是 Extension ClassLoader,Extension ClassLoader 的父类加载器是 Bootstrap ClassLoader,要注意的一点是,这里的父子并不是继承关系.

20210729085731

2-4-2:创建并使用自定义类加载器

自定义加载器的话,需要继承 ClassLoader 。如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法.

2-5:双亲委派模型

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

2-5-1:双亲委派模型带来了什么好处呢?

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

2-5-2: 双亲委派模型如何实现?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private final ClassLoader parent;
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查请求的类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理
c = parent.loadClass(name, false);
} else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//抛出异常说明父类加载器无法完成加载请求
}

if (c == null) {
long t1 = System.nanoTime();
//自己尝试加载
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

  1. 先检查类是否已经被加载过。
  2. 若没有加载则调用父加载器的 loadClass()方法进行加载。
  3. 若父加载器为空则默认使用启动类加载器作为父加载器。
  4. 如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass()方法进行加载。

2-5-3:如果我们不想⽤双亲委派模型怎么办?

因为双亲委派过程都是在 loadClass 方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的 loadClass 方法,使其不进行双亲委派即可。

2-5-4:loadClass()、findClass()、defineClass()区别

  • loadClass():主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
  • findClass():根据名称或位置加载.class 字节码。
  • definclass():把字节码转化为 Class。
1
2
3
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}

findClass()方法是 JDK1.2 之后的 ClassLoader 新添加的一个方法。findClass()方法只抛出了一个异常,没有默认实现。

在 loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的 findClass()方法来完成加载。所以,如果想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承 ClassLoader,并且在 findClass 中实现自己的加载逻辑即可。

2-5-5:破坏双亲委派模型的例子

  1. 第一种被破坏的情况是在双亲委派出现之前。
    由于双亲委派模型是在 JDK1.2 之后才被引入的,而在这之前已经有用户自定义类加载器在用了。所以,这些是没有遵守双亲委派原则的。
  2. 第二种是 JNDI、JDBC 等需要加载 SPI 接口实现类的情况。
  3. 第三种是为了实现热插拔热部署工具。为了让代码动态生效而无需重启,实现方式时把模块连同类加载器一起换掉就实现了代码的热替换。
  4. 第四种是 tomcat 等 web 容器的出现。
  5. 第五种是 OSGI、Jigsaw 等模块化技术的应用。

2-5-6:为什么 Tomcat 要破坏双亲委派?

Tomcat 是 web 容器,那么一个 web 容器可能需要部署多个应用程序。不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。
如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。所以,Tomcat 破坏双亲委派原则,提供隔离的机制,为每个 web 容器单独提供一个 WebAppClassLoader 加载器。

Tomcat 的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader 负责加载本身的目录下的 class 文件,加载不到时再交给 CommonClassLoader 加载,这和双亲委派刚好相反。

3:垃圾回收

3-1:什么是 GC

GC(Garbage Collection):垃圾回收。 在 C++中,对象所占的内存在程序结束运行之前一直被占用,在明确释放之前不能分配给其它对象;而在 Java 中,当没有对象引用指向原先分配给某个对象 的内存时,该内存便成为垃圾。 垃圾回收能自动释放内存空间,减轻编程的负担,JVM 的一个系统级线程会自动释放该内存块。垃圾回收意味着程序不再需要的对象是”无用信息”,这些信息将被丢弃。

3-2:垃圾回收器的基本原理是什么?

对于 GC 来说,创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是”可达的”,哪些对象是”不可达的”。当 GC 确定一些对象为”不可达”时,GC 就有责任回收这些内存空间。

3-2-1:垃圾回收器可以马上回收内存吗?

可以

3-2-2:有什么办法主动通知虚拟机进行垃圾回收?

手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。

3-3:如何判断对象已经死亡?

  • 引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
  • 可达性分析算法:这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

3-3-1:哪些可作为 GC Roots 的对象?

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 本地方法栈(Native 方法)中引用的对象
  3. 方法区中类静态属性引用的对象
  4. 方法区中常量引用的对象
  5. 所有被同步锁持有的对象

3-3-2:不可达的对象是否非死不可

在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

3-4:如何判断一个类是无用的类

类需要同时满足下面 3 个条件才能算是 “无用的类” :

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

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

3-5:如何判断一个常量是废弃常量?

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了

3-6:如何减少 GC 的次数

  1. 对象不用时最好显示置为 NULL
  2. 尽量少使用 System,gc()
  3. 尽量少使用静态变量
  4. 尽量使用 StringBuffer,而不使用 String 来累加字符串
  5. 分散对象创建或删除的时间
  6. 尽量少用 finaliza 函数
  7. 如果有需要使用经常用到的图片,可以使用软引用类型,将图片保存在内存中,而不引起 outofmemory
  8. 能用基本类型入 INT 就不用对象 Integer
  9. 增大-Xmx 的值

3-7:垃圾回收算法

共有 4 种垃圾回收算法:

  1. 标记-清除算法:该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。
  2. 标记-复制算法:它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
  3. 标记-整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
  4. 分代收集算法:只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

    比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

3-8:垃圾回收器

本部分转自Guide-垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

3-8-1:Serial 收集器

单线程收集器。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法。

3-8-2:ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

3-8-3:Parallel Scavenge 收集器

Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器。Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。新生代采用标记-复制算法,老年代采用标记-整理算法。

3-8-4:Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

3-8-5:Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

3-8-6:CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS 收集器是一种 “标记-清除”算法实现的,他的运作过程分为四个步骤:

  1. 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快。
  2. 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  3. 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  4. 并发清除:开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

20210729142050

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感
  • 无法处理浮动垃圾
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
3-8-6-1:产生 concurrent mode failure 真正的原因

CMS 收集器如果在并行清理的过程中老年代的空间不足以容纳应用产生的垃圾,则会抛出“concurrent mode failure”。

这是因为 CMS 采用标记清除算法,默认并不使用标记整理算法,可能会产生很多碎片,因此,这些碎片无法完成大对象向老年代转移,因此需要进行 CMS 在老年代的 Full GC 来合并碎片。

3-8-6-2:CMS 出现 FullGC 的原因
  1. 空间碎片太多
  2. 垃圾产生速度超过清理速度
3-8-6-3:CMS 怎么解决内存碎片的问题
  • 增大 Xmx 或者减少 Xmn
  • 在应用访问量最低的时候,在程序中主动调用 System.gc(),比如每天凌晨。
  • 在应用启动并完成所有初始化工作后,主动调用 System.gc(),它可以将初始化的数据压缩到一个单独的 chunk 中,以腾出更多的连续内存空间给新生代晋升使用。
  • 降低-XX:CMSInitiatingOccupancyFraction 参数以提早执行 CMSGC 动作,虽然 CMSGC 不会进行内存碎片的压缩整理,但它会合并老生代中相邻的 free 空间。这样就可以容纳更多的新生代晋升行为。
3-8-6-4:CMS GC 发生 concurrent mode failure 时的 full GC 为什么是单线程的?

因为当时没足够开发资源,所以没考虑这种情况。

3-8-7:G1 收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

3-8-8:CMS 与 G1 区别

  1. G1 垃圾回收器是 compacting 的,因此其回收得到的空间是连续的。这避免了 CMS 回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的 floating garbage。连续空间意味着 G1 垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用 bump-the-pointer 的方式;
  2. G1 回收器的内存与 CMS 回收器要求的内存模型有极大的不同。G1 将内存划分一个个固定大小的 region,每个 region 可以是年轻代、老年代的一个。内存的回收是以 region 作为基本单位的;

3-8-9:吞吐优先和响应优先的垃圾收集器如何选择

吞吐量优先:

  1. -Xmx4g -Xms4g -Xmn2g -Xss200k -XX:+UseParallelGC -XX:ParallelGCThreads=8
    说明:选择 Parallel Scavenge 收集器,然后配置多少个线程进行回收,最好与处理器数目相等。
  2. -Xmx4g -Xms4g -Xmn2g -Xss200k -XX:+UseParallelGC -XX:ParallelGCThreads=8 -XX:+UseParallelOldGC
    说明:配置老年代使用 Parallel Old
  3. -Xmx4g -Xms4g -Xmn2g -Xss200k -XX:+UseParallelGC -XX:MaxGCPauseMills=100
    说明:设置每次年轻代垃圾回收的最长时间。如何不能满足,那么就会调整年轻代大小,满足这个设置
  4. -Xmx4g -Xms4g -Xmn2g -Xss200k -XX:+UseParallelGC -XX:MaxGCPauseMills=100 -XX:+UseAdaptiveSizePolicy
    说明:并行收集器会自动选择年轻代区大小和 Survivor 区的比例。

响应优先:

  1. -Xmx4g -Xms4g -Xmn2g -Xss200k -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
    说明:设置老年代的收集器是 CMS,年轻代是 ParNew
  2. -Xmx4g -Xms4g -Xmn2g -Xss200k -XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=5 -XX:+UseCMSCompactAtFullCollection
    说明:首先设置运行多少次 GC 后对内存空间进行压缩,整理。同时打开对年老代的压缩(会影响性能)

3-9:为什么要分代回收?分代回收背后的思想?

在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

3-9-1:young gc、old gc、full gc、mixed gc 区别

  • young (Minor gc):指的是单单收集年轻代的 GC。
  • old gc:指的是单单收集老年代的 GC。
  • full gc:整堆回收,指的是收取整个堆,包括年轻代、老年代,如果有永久代的话还包括永久代。
  • mixed gc:这个是 G1 收集器特有的,指的是收集整个年轻代和部分老年代的 GC。

3-9-2:新生代的 GC 如何避免全堆扫描?

HotSpot 给出的解决方案是 一项叫做 卡表 的技术。

3-9-3:young gc 触发条件是什么?

大致上可以认为在年轻代的 eden 快要被占满的时候会触发 young gc。
为什么要说大致上呢?因为有一些收集器的回收实现是在 full gc 前会让先执行一下 young gc。比如 Parallel Scavenge,不过有参数可以调整让其不进行 young gc。
可能还有别的实现也有这种操作,不过正常情况下就当做 eden 区快满了即可。eden 快满的触发因素有两个,一个是为对象分配内存不够,一个是为 TLAB 分配内存不够。

3-9-4:TLAB 你了解多少?

TLAB(Thread Local Allocation Buffer),为一个线程分配的内存申请区域。这个区域只允许这一个线程申请分配对象,允许所有线程访问这块内存区域。

3-9-5:full gc 触发条件有哪些?

  • 在要进行 young gc 的时候,根据之前统计数据发现年轻代平均晋升大小比现在老年代剩余空间要大,那就会触发 full gc。
  • 有永久代的话如果永久代满了也会触发 full gc。
  • 老年代空间不足,大对象直接在老年代申请分配,如果此时老年代空间不足则会触发 full gc。
  • 担保失败即 promotion failure,新生代的 to 区放不下从 eden 和 from 拷贝过来对象,或者新生代对象 gc 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 full gc。
  • 执行 System.gc()、jmap -dump 等命令会触发 full gc。

4:JVM 内存模型(堆栈五大分区)

4-1:堆的分区

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

新生代内存(Young Generation)
老生代(Old Generation)
永生代(Permanent Generation)

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

4-1-1:为什么是年轻代中比例为 8:1:1

新生代中的对象 98%都是“朝生夕死”的(即:将被回收的对象:存活的对象 > 9:1),所以如果根据复制算法完成按照 1:1 的比例划分新生代的内存空间,将会造成相当大的浪费。

因此,JVM 开发人员将新生代分为一块较大的 Eden 区,和两块较小的 Survivor 区,每次可以使用来存放对象的是 Eden 区和其中一块 Survivor 区。当回收时,将 Eden 区和 Survivor from 中还存活着的对象一次性复制到另一块 Survivor to 区(这里进行复制算法),然后就清空调 Eden 区和 Survivor from 区中的数据。

这样新生代中可用的内存:复制算法所需要的担保内存 = 9:1,这样即使所有的对象都不会存活,那么也只会“浪费”10%的内存空间。不过我们也无法保证存活的对象一定<2%或 10%,当新生代中 Survivor to 区内存不够用时,就会触发老年代的担保机制进行分配担保。

4-1-2:对象在堆内存移动

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度,就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

4-1-3:Java 中对象并不是都在堆上分配内存的

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

为什么要这样呢?为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

4-2:栈

JVM 分为 Java 虚拟机栈和本地方法栈。虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

4-2-1:栈的实现

线程启动时会创建虚拟机栈,每个方法在执行时会在虚拟机栈中创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法返回地址、附加信息等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈(压栈)到出栈(弹栈)的过程。

4-2-2:栈帧

4-2-2-1:栈帧结构

每个栈帧包含 5 个组成部分:局部变量表、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法出口(Return Address)和一些附加信息。

4-2-3:虚拟机栈和本地方法栈为什么是私有的?

为了保证线程中的局部变量不被别的线程访问到。

4-3:堆与栈的区别

  1. 栈内存存储的是局部变量而堆内存存储的是实体
  2. 栈内存的更新速度要快于堆内存,因为局部变量的生命周期很短
  3. 栈内存存放的变量生命周期一旦结束就会被释放,而堆内存存放的实体会被垃圾回收机制不定时的回收。

4-4:程序计数器

程序计数器是一块较小的内存空间,几乎可以忽略不记,也是运行速度最快的存储区域(因为只存放了指向下一条指令的地址)。可以看作是当前线程所执行的字节码的行号指示器。

4-4-1:为什么要有程序计数器(作用)

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

4-4-2:程序计数器为什么是私有的?

为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

4-5:元空间

4-5-1:Java 8 的 metaspace (元空间)

对于 Java8, HotSpots 取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在。那么取代永久代的就是元空间。它和永久代有什么不同?

  • 存储位置不同,永久代物理上是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;
  • 存储内容不同,元空间存储的是类的元信息,而字符串常量池和静态变量存入堆中。

4-5-2:为什么要进行元空间代替持久代呢?

  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了,而由系统的实际可用空间来控制,这样能加载的类就更多了。
  3. 在 JDK 1.8 中,合并 HotSpot 和 JRockit 的代码时,JRockit 从来没有一个叫永久代的东西,合并之后就没有必要额外的设置这么一个永久代的地方了。

5:Java 内存模型(JMM)

5-1: 什么是 Java 内存模型?

Java 内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

5-1-1:Java 内存模型的两大内存是啥?

主内存和工作内存。

Java 内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

6:Java 对象的创建过程

6-1:内存分配的两种⽅式选择

分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

20210802084515

6-2:对象分配过程如何保证线程安全?

一般有两种解决方案:

  1. 对分配内存空间的动作做同步处理,采用 CAS 机制,配合失败重试的方式保证更新操作的线程安全性。
  2. 每个线程在 Java 堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,当这部分区域用完之后,再分配新的”私有”内存。

方案 1 在每次分配时都需要进行同步控制,这种是比较低效的。
方案 2 是 HotSpot 虚拟机中采用的,这种方案被称之为 TLAB 分配,即 Thread Local Allocation Buffer。这部分 Buffer 是从堆中划分出来的,但是是本地线程独享的。

这里值得注意的是,我们说 TLAB 是线程独享的,但是只是在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。另外,TLAB 仅作用于新生代的 Eden Space,对象被创建的时候首先放到这个区域,但是新生代分配不了内存的大对象会直接进入老年代。因此在编写 Java 程序时,通常多个小的对象比大的对象分配起来更加高效。所以,虽然对象刚开始可能通过 TLAB 分配内存,存放在 Eden 区,但是还是会被垃圾回收或者被移到 Survivor Space、Old Gen 等。

不知道大家有没有想过,我们使用了 TLAB 之后,在 TLAB 上给对象分配内存时线程独享的了,这就没有冲突了,但是,TLAB 这块内存自身从堆中划分出来的过程也可能存在内存安全问题啊。

所以,在对于 TLAB 的分配过程,还是需要进行同步控制的。但是这种开销相比于每次为单个对象划分内存时候对进行同步控制的要低的多。

虚拟机是否使用 TLAB 是可以选择的,可以通过设置-XX:+/-UseTLAB 参数来指定。

6-3:对象的访问定位有哪两种⽅式?

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
    20210802113028
  2. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
    20210802113038

6-3-1:访问定位两种方式的优缺点

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。缺点就是定位对象时需要定位两次。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。缺点就是当对象被移动时,所有指向该对象的 reference 都需要被改变,耗时。

6-4:对象头

Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

对markword的设计方式上,非常像网络协议报文头:将mark word划分为多个比特位区间,并在不同的对象状态下赋予比特位不同的含义。下图描述了在32位虚拟机上,在对象不同状态时 mark word各个比特位区间的含义。
20210803091357

6-5: 对象分配规则

对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。

大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝。

7:内存泄露与内存溢出

7-1:什么是内存泄漏

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

7-1-1:如何检测内存泄漏

  1. 识别症状:在许多情况下,Java进程最终会抛出一个OOM运行时异常,这是一个明确的指示,表明内存资源已经耗尽。在这种情况下,需要区分正常的内存耗尽和泄漏。分析OOM的消息并尝试找到罪魁祸首。
  2. 启用详细垃圾回收:通常通过检查verbosegc输出中的模式来识别内存约束问题。具体来说,-verbosegc参数允许您在每次垃圾收集(GC)过程开始时生成跟踪。也就是说,当内存被垃圾收集时,摘要报告会打印到标准错误,让您了解内存的管理方式。此GC跟踪文件中的每个块(或节)按递增顺序编号。要理解这种跟踪,您应该查看连续的分配失败节,并查找随着时间的推移而减少的释放内存(字节和百分比),同时总内存(此处,19725304)正在增加。这些是内存耗尽的典型迹象。
  3. 启用分析:不同的JVM提供了生成跟踪文件以反映堆活动的不同方法,这些方法通常包括有关对象类型和大小的详细信息。这称为分析堆。
  4. 分析踪迹:跟踪可以有不同的格式,因为它们可以由不同的Java内存泄漏检测工具生成,但它们背后的想法总是相同的:在堆中找到不应该存在的对象块,并确定这些对象是否累积而不是释放。特别感兴趣的是每次在Java应用程序中触发某个事件时已知的临时对象。应该仅存少量,但存在许多对象实例,通常表示应用程序出现错误。

最后,解决内存泄漏需要您彻底检查代码。了解对象泄漏的类型可能对此非常有用,并且可以大大加快调试速度。

7-2:什么是内存溢出

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存,从而没有足够的内存空间供其使用。

7-2-1:手写出现内存溢出的情形

  1. 堆内存溢出(Java heap space)
  2. 堆内存泄漏
  3. 垃圾回收超时内存溢出
  4. Meatspace内存溢出(Metaspace)
  5. 直接内存内存溢出(Direct buffer memory)
  6. 栈内存溢出
  7. 创建本地线程内存溢出
  8. 超过交换区内存溢出
  9. 数组超限内存溢出(Requested array size exceeds VM limit)
  10. 系统杀死进程内存溢出

7-3:内存溢出,内存泄漏区别

内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。

内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

memory leak会最终会导致out of memory!

7-4:如何避免内存泄露、溢出

  1. 尽早释放无用对象的引用。
  2. 程序进行字符串处理时,尽量避免使用String,而应使用StringBuffer。
  3. 尽量少用静态变量。
  4. 避免集中创建对象尤其是大对象,如果可以的话尽量使用流操作。
  5. 尽量运用对象池技术以提高系统性能。
  6. 不要在经常调用的方法中创建对象,尤其是忌讳在循环中创建对象。

8:补充

8-1:强、软、弱、虚引用

  1. 强引用:以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
  2. 弱引用:如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
  3. 软引用:如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
  4. 虚引用:“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
    特别注意,在实际程序设计中一般很少使用弱引用与虚引用,使用软用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

8-2:JVM 进程有哪些线程启动? (拼多多)

首先要明白一点:JVM本身是一个多线程的程序,和我们编写的java应用程序一样,当JVM启动执行时就是在操作系统中启动了一个JVM进程。我们编写的java单线程或多线程应用进程都是在JVM这个程序中作为一个或多个线程运行。

每当使用java命令执行一个带main方法的类时,就会启动JVM(应用程序),实际上就是在操作系统中启动一个JVM进程,JVM启动时,必然会创建以下5个线程:

  1. main: 主线程,执行我们指定的启动类的main方法
  2. Reference Handler: 处理引用的线程
  3. Finalizer: 调用对象的finalize方法的线程,就是垃圾回收的线程
  4. Signal Dispatcher: 分发处理发送给JVM信号的线程  
  5. Attach Listener: 负责接收外部的命令的线程

Attach Listener :该线程是负责接收到外部的命令,执行该命令,并且把结果返回给发送者。通常我们会用一些命令去要求jvm给我们一些反馈信息,如:java -version、jmap、 jstack等等。如果该线程在jvm启动的时候没有初始化,那么,则会在用户第一次执行jvm命令时,得到启动。

signal dispather: 前面我们提到第一个Attach Listener线程的职责是接收外部jvm命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并 且返回处理结果。signal dispather线程也是在第一次接收外部jvm命令时,进行初始化工作。

Finalizer: JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收。

Reference Handler :它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题。

main:主线程,用于执行我们编写的java程序的main方法。

8-3:jvm 启动模式之 client 与 server

JVM有两种运行模式Server与Client。两种模式的区别在于,Client模式启动速度较快,Server模式启动较慢;但是启动进入稳定期长期运行之后Server模式的程序运行速度比Client要快很多。这是因为Server模式启动的JVM采用的是重量级的虚拟机,对程序采用了更多的优化;而Client模式启动的JVM采用的是轻量级的虚拟机。所以Server启动慢,但稳定后速度比Client远远要快。

8-4:简述 JVM 中静态分派和动态分派(引申:重载和重写)

由于动态分派是非常频繁的操作,实际实现中不可能真正如此实现。Java虚拟机是通过“稳定优化”的手段——在方法区中建立一个虚方法表(Virtual Method Table),通过使用方法表的索引来代替元数据查找以提高性能。虚方法表中存放着各个方法的实际入口地址(由于Java虚拟机自己建立并维护的方法表,所以没有必要使用符号引用,那不是跟自己过不去嘛),如果子类没有覆盖父类的方法,那么子类的虚方法表里面的地址入口与父类是一致的;如果重写父类的方法,那么子类的方法表的地址将会替换为子类实现版本的地址。

方法表是在类加载的连接阶段(验证、准备、解析)进行初始化,准备了子类的初始化值后,虚拟机会把该类的虚方法表也进行初始化。