运行时数据区域

JDK 1.7:
1.7

JDK 1.8:
1.8

图源自:javaguide.cn

线程私有

程序计数器

主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:循序执行、选择、循环等。
  • 多线程情况下,程序计数器用于记录当前线程执行的位置,以便线程被切换回来时能够正常运行。

程序计数器的生命周期随着线程的创建而创建,随着线程的结束而死亡。

虚拟机栈

与程序计数器一样,它的生命周期也与线程共存亡。
虚拟机栈由一个个栈帧组成,而栈帧中拥有:

  • 局部变量表:存放编译器可知的各种数据类型和对象引用。
  • 操作数栈:用于存放方法执行过程中产生的中间结果和临时变量。
  • 动态链接:将符号引用转化为调用方法的直接引用,作用于一个方法需要调用其他方法的场景。
  • 方法返回地址:正常退出时,返回调用该方法指令的下一条指令的地址;异常退出时,返回地址和通过异常表来确认,栈帧一般不会保存此信息。

本地方法栈

与虚拟机栈的职责类似,虚拟机栈执行Java方法,而本地方法栈执行Native方法。

线程共有

用于存放JAVA对象的实例,JDK 7中,堆内存分为以下三种:

  1. 新生代
  2. 老年代
  3. 永久代

JDK 8之后永久代被元空间取代,对象晋升过程为:

  1. 首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且年龄加1。
  2. 如果在此后的GC中均存活(每次GC年龄加1),则会进入老年代(阈值默认为15)。

为社么将永久代替换为元空间?

  1. 永久代有一个固定的内存上限且无法调整,而元空间使用的是本地内存,溢出概率比永久代更小。
  2. 元空间存放的是类的元数据,由系统的实际可用空间控制,能加载更多类。

字符串常量池

JVM为了提升性能和减少内存消耗(避免字符串重复创建)对字符串专门开辟的一块区域。
JDK 8把字符串常量池从永久代移动到了堆中,主要为了提高GC效率。

运行时常量池

主要用于存放JAVA程序运行时的符号表。

垃圾回收

1.8
Eden、S0、S1都属于新生代,Tenured为老年代,在下面的一层原本为永久代,JDK 1.8中已被元空间取代(使用直接内存)。

内存分配和回收原则

Eden区

对象首先在Eden区分配,当Eden区没有足够空间进行分配时,就会进行一次Minor GC,如果GC期间虚拟机发现对象无法存入Survivor空间,就会通过分配担保机制把新生代对象提前转入老年代,若老年代空间不够存放,就会执行Full GC。如果Eden腾出空间,后面的对象还是会在Eden区分配内存。

分配担保机制:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

老年代

诸如数组、字符串等需要开辟大量连续空间的大对象直接进入老年代。
如果在分配在Eden区的对象经历过一次Minor GC后仍然存活,就进入S0或S1中并将年龄设置为1,此后,每经过一次Minor GC就把年龄加1,到达年龄阈值后(默认为15)进入老年代。

GC区域

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
  • 整堆收集(Full GC):收集整个堆和方法区。

如何判断对象能否被回收

引用计数法

给对象添加一个引用计数器:

  • 每当有一个地方引用它,计数器加1;
  • 引用失效,计数器减1;
  • 计数器为0代表此对象不再使用。

这个方法的优点是实现简单且效率高,但无法解决对象间循环引用的问题,这会导致循环引用的对象计数器都不为0,从而无法通知GC回收他们。

可达性分析法

通过一系列“GC Roots”的对象作为起点,从这个点向下搜索,节点走过的路径称为引用链,当一个对象到GC Roots间没有任何引用链时,代表此对象已不可用,需要GC。

不可达的对象不一定会被回收,需要进行两次标记:

  • 不可达对象被第一次标记并进行一次筛选,判断此对象是否有必要执行finalize方法,当对象没有覆盖finalize方法或者finalize方法已被调用过,虚拟机就不会回收。
  • 被标记的对象会被放入一个队列进行第二次标记,如果此对象没有与引用链上的任何一个对象建立关联,则会被回收。

引用类型

无论是引用计数法还是可达性分析法,都与引用有关,JAVA共有四种引用类型:

  1. 强引用
    当一个对象具有强引用,垃圾回收器不会回收它,当内存空间不足时则抛出OutOfMemoryError
  2. 软引用
    如果内存空间充足,垃圾回收器不会回收它;如果内存空间不足,那么它就拜拜了。
    软引用可以和一个引用队列联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
  3. 弱引用
    在GC线程扫描内存区域时,一旦发现只具有弱引用的对象,不管内存是否充足,都会执行垃圾回收。
  4. 虚引用
    虚引用主要用来跟踪对象被垃圾回收的活动。
    当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,将这个虚引用加入引用队列,在其关联的虚引用出队前,不会彻底销毁该对象。即可以用虚引用来判断对象是否已经被回收了。

由于ThreadLocal中的key是弱引用,所以在gc时会被回收,而value是强引用则不会,如果不做任何措施,value就永远无法被gc,这时就有可能出现内存泄露问题,所以我们要手动删除。

垃圾回收算法

标记-清除

首先标记出所有不需要回收的对象,在标记完成后统一回收没有被标记的对象。
它有两个重要缺陷:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:清除后会产生大量不连续的内存碎片。

标记-复制

将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。每次对内存的一半进行回收。
它同样有两个问题:

  1. 可用内存变小:可用内存缩小为原来的一半。
  2. 不适合老年代:老年代存活数量较大,复制性能较差。

标记-整理

主要变化在整理步骤,让所有存活的对象向一端移动,然后清理掉边界外的内存。

分代收集

这三种算法都有自己适用的场景,可以将它们组合使用:

  • 新生代中每次收集都会有大量对象死亡,可以选择标记-复制算法;
  • 老年代中对象存活概率较高,且没有额外的空间对它们进行分配担保,就必须选择标记-清除或者标记-整理。

垃圾回收器

Serial

最古老的单线程垃圾回收器。在它开启垃圾回收线程时,其他线程必须暂停工作。
新生代采用标记-复制算法,老年代采用标记-整理算法。
优点:简单且高效:没有线程交互的开销
缺点:Stop The World

ParNew

Serial的多线程版本。
新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Scavenge

和ParNew差不多。有自适应调节策略,可以找到最合适的停顿时间或最大吞吐量,提高用户体验。
新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old

Serial的老年代版本,也是单线程。主要用途是作为CMS的后备方案。

Parallel Old

Parallel Scavenge的老年代版本,使用多线程和标记-整理算法。主要用于注重吞吐量和CPU资源分配的场景。

CMS

一种以获取最短回收停顿时间为目标的收集器,可以让垃圾回收线程和用户线程同时工作。使用标记-清除算法。
运作工程分为四个步骤:

  • 初始标识:暂停所有其他线程,并记录与root相连的对象;
  • 并发标记:同时开启GC和用户线程,用一个闭包结构记录可达对象,由于用户线程可能不断更新引用域,所以它无法保证可达性分析的实时性。
  • 重新标记:为了修正用户线程在并发标记期间产生的变动。
  • 并发清除:开启用户线程,同时GC线程开始对未标记的区域清扫。

优点:并发收集,低停顿
缺点:

  • 对CPU资源敏感:如果在CPU资源不足的情况下应用会有明显的卡顿;
  • 无法处理浮动垃圾:在并发清除这个步骤,用户线程可能会同时产生一部分可回收对象,而这部分只能在下次执行清理时才会被回收。如果在清理过程中预留给用户线程的内存不足就会出现Concurrent Mode Failure,这时就需要Serial Old来兜底。
  • 内存碎片:使用标记-清除算法的弊端,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发FullGC,且在1.9后将其废除。

G1

面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。
特点:

  • 并发与并行:G1充分发挥多核性能,使用多CPU来缩短Stop-The-world的时间;
  • 分代回收:G1能够自己管理不同分代内已创建对象和新对象的收集;
  • 空间整合:整体基于标记-整理,局部基于标记-复制。两种算法都不会产生内存碎片。
  • 可预测的停顿:让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

类加载机制

以下部分是我根据JavaGuide和网上查阅的一些资料做的总结。

  • 类加载过程:加载、连接、初始化。
  • 连接过程可分为:验证、准备、解析。

加载是类加载过程的第一步,主要完成3件事:

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

类加载器

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
  • 每个Java类都有一个引用指向它的ClassLoader
  • 数组不是通过ClassLoader创建的(数组中没有对应的二进制字节流),是由JVM直接生成的。

加载规则

JVM启动时并不会一次性加载所有的类,而是根据需要的去动态加载。即大部分类在具体用到时才回去加载,这样对内存更加友好。这让我想到了Spring boot在自动装配时也并不是一次性装配所有的类,而是按需装配。

在类加载的时候,系统会首先判断当前类是否已经被加载过,只有没被加载过的类才会尝试加载。即对以一个类加载器来说,相同的二进制名称只会加载一次。

ClassLoader

  • BootstrapClassLoader(启动类加载器):最顶层的加载类,通常为NULL,并且没有父级,主要用来加载 JDK 内部的核心类库以及被 -Xbootclasspath参数指定的路径下的所有类。
  • ExtensionClassLoader(扩展类加载器):负责加载JVM扩展类,比如swing系列,内置的js引擎,库名通常以javax开头。
  • AppClassLoader :面向用户的加载器,负责加载当前classpath下的所有jar包和类。

为此,我做了个表格以便更清晰的区分三种类加载器:

名称 范围 父级
BootstrapClassLoader 核心类 null
ExtensionClassLoader 扩展类 BootstrapClassLoader
AppClassLoader classpath下的所有jar包和类 ExtensionClassLoader

注意:这里的父子关系通常不是以继承方式实现的!

具体的实现方式参考:https://blog.csdn.net/CPLASF_/article/details/120403702
此外,用户还可以自定义类加载器,它的直接父类是AppClassLoader,多个自定义类加载器可以有同一个父类,并且自定义类加载器的父类可以是自定义类加载器。

每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,至于为什么BootstrapClassLoader获取的为null,是因为它是由C++实现的,Java中没有与之对应的类。

双亲委派模型

官网介绍如下:
ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 “bootstrap class loader”的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。

概括如下:

  • ClassLoader类使用委托模型来搜索类和资源。
  • 双亲委派模型要求除了BootstrapClassLoader外,其余的类加载器都应有自己的父类加载器。
  • ClassLoader实例会在试图查找资源之前,将搜索类或资源的任务委托给其父类加载器。

可以用以下代码测试双亲委派模型的实现:

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
38
39
40
41
42
import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String filename = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(filename);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException ex) {
throw new ClassNotFoundException(name);
}
}


};
Object obj = myLoader.loadClass("com.lbn.study.testdemo.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.lbn.study.testdemo.ClassLoaderTest);

System.out.println(obj.getClass().getClassLoader());
System.out.println(obj.getClass().getClassLoader().getName());
System.out.println(obj.getClass().getClassLoader().getParent());
System.out.println(obj.getClass().getClassLoader().getParent().getName());

System.out.println(ClassLoaderTest.class.getClassLoader());
System.out.println(ClassLoaderTest.class.getClassLoader().getParent());
System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getParent());
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(ClassLoader.getPlatformClassLoader());
System.out.println(ClassLoaderTest.class.getClassLoader().getName());
System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getName());
}
}

需要注意的是,类加载器之间的父子关系一般不建议用继承实现,而是通过组合关系复用父加载器的代码。(组合优于继承)

1
2
3
4
5
6
7
8
9
public abstract class ClassLoader {
...
// 组合
private final ClassLoader parent;
protected ClassLoader(ClassLoader parent) {
this(checkCreateClassLoader(), parent);
}
...
}

执行流程

源码如下:

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
38
39
40
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}

if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
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) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
  • 类加载时,首先判断该类是否已被加载过,已被加载过的类直接返回,否则才会尝试加载。
  • 把请求委托给父类加载器。
  • 当父加载器无法完成这个加载请求,子加载器才会尝试自己去加载。

向上委派是到顶层类加载器为止,向下查找是到发起的加载器为止,如果是有自定义类加载的情况,发起和截至会是这个自定义加载器。

双亲委派模型的好处

javaguide举了一个非常好的例子:比如我们编写一个称为java.lang.Object类的话,程序运行时就会有两个不同的Object类,双亲委派模型可以保证加载的是JRE中的Object类,避免造成歧义。因为AppClassLoader在加载此类之前,会委托给ExtClassLoader,而ExtClassLoader又会委托给BootstrapClassLoaderBootstrapClassLoader发现已经加载过了Object类,就会直接返回,而不是去加载我们自定义的Object类。

JVM判断两个类是否相同有两个条件,首先全类名必须相同,其次加载此类的类加载器也必须相同:

  • java.lang.Object存放在rt.jar中,它的类加载器是BootstrpClassLoader
  • 自定义的Object存放在classpath下,它的类加载器是AppClassLoader
    所以,即使全类名相同,它们也不是两个相同的类,程序的编译也可以正常通过。