JVM 运行时数据区域
jdk 8 的 JVM 内存结构图
程序计数器(寄存器)
-
作用:存储当前线程即将执行的指令代码的地址
-
为什么要记录当前线程的执行地址 因为 CPU 需要不停的切换各个线程,切换回来以后,就得知道接着从哪开始继续执行。
-
特点
- 线程私有
- 不会存在内存溢出
- 是一块很小的内存空间,也是运行速度最快的存储区域
- 分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
虚拟机栈(Stack Frame)
- 每个线程运行时所需要的内存,称为虚拟机栈,是线程私有的
- 每个栈保存了多个栈帧(Stack Frame) ,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
- 不存在垃圾回收问题
虚拟机栈对应的是方法的内存区域,每个方法执行时都会创建一个栈帧,用来存储该方法的局部变量表,操作数栈,动态链接,方法返回地址。
可以通过参数 -Xss 来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
- 局部变量表:局部变量表中存储的是方法的参数和方法中定义的局部变量,在编译期间就为局部变量表分配好了内存空间。局部变量表中存储三种类型的数据:
(1) 基本数据类型
(2) 引用类型:指向一个对象在内存中的地址
(3) returnAddress 类型:指向指令的地址(已经很少见了,指向异常处理的指令,现在已经由异常表代替)
-
操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。当虚拟机执行一些指令的时候会对操作数栈进行入栈或出栈的操作,比如 iadd 指令将两个数相加,会先将操作数栈中的两个数弹出来(出栈),相加后再压入栈(入栈)中。
-
动态链接:在运行时常量池中存储了诸如类名,方法名,我们要找到目标类,执行相应的方法就需要用到动态链接,栈帧中有一个指向运行时常量池的引用,通过这个引用可以找到相应的类名和方法名,但是光知道名称是没法执行方法的,需要通过名称找到相应的类和方法在内存中的地址,这个过程就是动态链接。
-
方法返回地址:当方法执行完以后如果有返回值,就会把这个返回值返回给该方法的调用者,方法的返回就是我们 Java 中用到的 return 命令。方法返回之后调用者需要继续往下执行就需要知道要执行的地址,该地址就是方法返回地址,它被记录在了栈帧中,当然在发生异常的情况下不会有返回值,要继续执行的地址可以通过异常处理器表来确定。
虚拟机栈可能出现两种类型的异常:
- 虚拟机栈空间不能动态扩展的情况下,线程请求的栈深度大于虚拟机允许的栈深度会抛出 StackOverflowError(比如递归调用,没有设置停止条件)
- 如果虚拟机栈空间可以动态扩展(目前多数的虚拟机都可以),当动态扩展无法申请到足够的空间时会抛出 OutOfMemory 异常。
本地方法栈
本地方法栈也是线程私有的。本地方法栈与虚拟机栈的作用是一样的,区别在于虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机执行 native 方法服务,native 方法为本地方法,不是用 Java 语言写的有可能是 c 或者 c++ 写的,在 jdk 中就有很多 c 的代码,就是提供给本地方法来调用的。
在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。
Note
栈是运行时的单位,而堆是存储的单位 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据 堆解决的是数据存储的问题,即数据怎么放、放在哪
堆内存
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。 为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 新生代(年轻代):新对象和没达到一定年龄的对象都在新生代
- 老年代:被长时间使用的对象,老年代的内存空间应该要比年轻代更大
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
年轻代 (Young Generation)
年轻代是所有新对象创建的地方。当年轻代即将填满时,执行垃圾收集(称为 Minor GC)年轻代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为 from/to 或 s0/s1),默认比例是 8:1:1
- 大多数新创建的对象都位于 Eden 内存空间中
- 当 Eden 空间被对象填充时,执行 Minor GC,采用的是复制回收算法
- Minor GC 会引发 stop the world,暂停其他用户线程,这个过程一般很快
- 第一次触发 Minor GC 时,将 Eden 区的幸存者对象移动到 s0 区,然后清理 Eden 区
- 下次触发 Minor GC 时,将 Eden 区和 s0 区的幸存者对象移动到 s1 区,然后清理 Eden 区和 s0 区,如此循环
- 每次触发 Minor GC 之后,存活对象的年龄会+1,当对象寿命超过阈值时,会晋升至老年代,最大寿命是 15 (4bit),可以通过参数配置(-XX: MaxTenuringThreshold)
老年代(Old Generation)
老年代包含经过许多轮 Minor GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 Full GC,通常需要更长的时间(stop the world)
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝
元空间
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
设置堆内存大小和 OOM
Java 堆用于存储 Java 对象实例,因此堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定
- -Xms 用来表示堆的起始内存,等价于 -XX: InitialHeapSize
- -Xmx 用来表示堆的最大内存,等价于 -XX: MaxHeapSize
如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。
我们通常会将 -Xmx 和 -Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能
- 默认情况下,初始堆内存大小为:电脑内存大小/64
- 默认情况下,最大堆内存大小为:电脑内存大小/4
可以通过代码获取到我们的设置值,当然也可以模拟 OOM
public static void main(String[] args) {
//返回 JVM 堆大小
long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
//返回 JVM 堆的最大内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
System.out.println("-Xms : "+initalMemory + "M");
System.out.println("-Xmx : "+maxMemory + "M");
System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
}
对象在堆中的生命周期
- 在 JVM 内存模型的堆中,堆被划分为新生代和老年代
- 新生代又被进一步划分为 Eden 区 和 Survivor 区,Survivor 区由 From Survivor 和 To Survivor 组成
- 当创建一个对象时,对象会被优先分配到新生代的 Eden 区
- 此时 JVM 会给对象定义一个对象年轻计数器(-XX: MaxTenuringThreshold)
- 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
- 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
- 如果分配的对象超过了-XX: PetenureSizeThreshold,对象会直接被分配到老年代
GC 垃圾回收简介
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
-
部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
-
新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
-
老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
-
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前只有 G1 GC 会有这种行为
-
-
整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾
方法区(Method Area)
- 与 Java 堆一样,是所有线程共享的内存区域。
- 实现方式:
- jkd 6:永久代,使用 JVM 内存,经常 OOM
- jdk 7:转移一部分存储在永久代的数据。符号引用(Symbols)转移到了 native heap;字面量(interned strings)转移到了 java heap;类的静态变量(class statics)转移到了 java heap
- jdk 8:元空间,使用本地内存,大大降低了发生 OOM 的概率
- 作用:存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
- 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用(字面量相当于 Java 里常量的概念,比如字符串,声明为 final 的常量值等,符号引用包括了:类和接口名,字段名,方法名),这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern()方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
- 方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误。
- JVM 关闭后方法区即被释放。
永久代(PermGen)与元空间(Metaspace)的区别
- 方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT 编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代物理是堆的一部分,和新生代,老年代一样,地址是连续的(受垃圾回收器管理)而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生 OOM(都会有溢出异常)
- Java7 中我们通过-XX: PermSize 和 -xx: MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过 -XX: MetaspaceSize 和 -XX: MaxMetaspaceSize 用来设置元空间参数
- 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
- 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出 OutOfMemoryError
- JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)
设置方法区内存的大小
JDK8 及以后:
- 元数据区大小可以使用参数 -XX: MetaspaceSize 和 -XX: MaxMetaspaceSize 指定,替代上述原有的两个参数。
- 默认值依赖于平台。Windows 下,-XX: MetaspaceSize 是 21M,-XX: MaxMetaspacaSize 的值是 -1,即没有限制。
- 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常 OutOfMemoryError: Metaspace
- -XX: MetaspaceSize :设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的 -XX: MetaspaceSize 的值为 20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize 时,适当提高该值。如果释放空间过多,则适当降低该值。
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将 -XX: MetaspaceSize 设置为一个相对较高的值。
移除永久代的原因
http://openjdk.java.net/jeps/122
-
为永久代设置空间大小是很难确定的。
- 在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现 OOM。而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制
-
对永久代进行调优较困难
栈、堆、方法区的交互关系
StringTable(字符串常量池)
-
底层结构:
HashSet<String>
-
只存储对 String 实例的引用,而不存储 String 对象的内容
-
常量池中的字符串仅是符号,第一次用到时才变为对象
-
利用常量池的机制,来避免重复创建字符串对象
-
字符拼接的原理是 StringBuilder (1.8)
-
字符串常量拼接的原理是编译期优化
-
可以使用 intern 方法,主动将常量池中还没有的字符串对象放入常量池
-
位置:jdk 1.8 在堆内存(heap),jdk 1.6 在永久代(PermGen)
- 永久代只有 Full GC 才会回收,而 Full GC 在老年代满的时候才会触发,所以 StringTable 放在永久代回收频率太慢,这才把 StringTable 放在堆内存里。
String s = new String("a") + String("b"); String s2 = s.intern(); //将字符串对象尝试放入常量池
- jdk 1.8 将字符串对象尝试放入常量池,如果有则并不会放入,如果没有则放入常量池;返回值是常量池中的对象(因此 s2 = s)
- jdk 1.6 将字符串对象尝试放入常量池,如果有则并不会放入,如果没有则把此对象复制一份,放入常量池;返回值是常量池中的对象(因此 s2 != s)
面试题
//StringTable = ["a","b","ab"]
String s1 = "a";
String s2 = "b";
String s3 = "a"+"b"; //常量字符拼接,在编译期间放入常量池
//变量是不确定的,在运行期间利用StringBuilder 拼接字符,
//并调用toString()生成新的String对象,对象位于堆里
String s4 = s1 + s2;
String s5 = "ab"; //直接引用常量池的"ab",不会生成新的对象
String s6 = s4.intern(); //返回常量池的"ab"
System.out.println(s3 == s4); //false
System.out.println(s3 == s5); //true
System.out.println(s3 == s6); //true
String x2 = new String("c") + new String("d"); //"cd"在堆
String x1 = "cd"; //"cd"在常量池
x2.intern(); //常量池已有
System.out.println(x1 == x2); //false
StringTable 性能调优
- 如果项目存在大量重复的字符串,可以将字符串入池(intern),减少内存占用
- 如果项目中字符串常量比较多,建议给 StringTable 设置大一点
// StringTableSize must be between 1009 and 230584300921369395
-XX:StringTableSize=200000
直接内存(Direct Memory)
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
- 内存用完了,会发生内存溢出
public class Demo {
static int _1Gb = 1024*1024*1024;
public static void main(String[] args) throws I0Exception {
Unsafe unsafe = getunsafe();
//分配内存
long base = unsafe.allocateMemory(_1Gb) ;
unsafe.setMemory(base, _1Gb, (byte) 0);
System.in.read();
//释放内存
unsafe.freeMemory(base);
System.in.read();
}
//通过反射获取 Unsafe 对象
public static Unsafe getUnsafe() {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");|
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
- 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
- ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 方法来释放直接内存
垃圾回收
垃圾收集主要是针对堆和方法区进行;程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。
判断一个对象是否可被回收
引用计数算法
给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。 两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。 正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
可达性分析算法
通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。
Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类的静态属性引用的对象
- 方法区中的常量引用的对象
方法区的回收
-
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。
-
主要是对常量池的回收和对类的卸载。
-
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景,都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
-
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
-
可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。
finalize()
finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
引用类型
强引用
- 被强引用关联的对象不会被回收。
- 使用 new 的方式来创建强引用。
Object obj = new Object();
软引用
- 被软引用关联的对象只有在内存不够的情况下才会被回收。
- 使用 SoftReference 类来创建软引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
弱引用
- 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
- 使用 WeakReference 类来实现弱引用。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
虚引用
- 又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。
- 为一个对象设置虚引用关联的唯一目的,是能在这个对象被回收时收到一个系统通知。
- 使用 PhantomReference 来实现虚引用。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;
垃圾回收算法
标记 - 清除
将存活的对象进行标记,然后清理掉未被标记的对象
特点:
- 标记和清除过程效率都不高
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存
标记 - 整理
标记存活的对象,未被标记的对象会被清理;在清除的过程中,让存活的对象向前移动,避免产生空间碎片
特点:
- 效率比标记 - 清除算法慢
- 不会产生内存碎片
复制
将内存划分为大小相等的两块(from,to),每次只使用其中一块(from)。垃圾回收时将存活的对象复制到另一块上(to),同时对空间进行整理。然后把 from 区进行清理,最后调换 to 区和 from 区的位置(原来的 from 区变成 to 区,原来的 to 区变成 from 区)
特点:
- 需要 2 倍内存;
- 不会产生内存碎片。
现在的商业虚拟机都采用复制算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。 HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代(YoungGC): 复制算法
- 老年代(OldGC): 标记 - 清除 或者 标记 - 整理 算法
垃圾收集器
HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
灵魂画法
- Serial 是单线程的,Parallel 是多线程
- 串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并形指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
┎ Serial:单线程的年轻代垃圾收集器,使用复制算法,收集过程会 STW(停止所有的用户线程)
┖ Serial Old:单线程的老年代垃圾收集器,使用标记-整理算法,收集过程会 STW
┎ ParNew:Serial 收集器的多线程版本
┖ CMS:CMS(Concurrent Mark Sweep)收集器是一种以最短回收停顿时间为目标的收集器,它可以避免「老年代 GC」出现「长时间」的卡顿。CMS 使用的是“标记—清除”算法,它的收集过程可以分为 5 个步骤,包括:初始标记、并发标记、并发预清理、重新标记、并发清除
┎ Parallel Scavenge:多线程的年轻代垃圾收集器,使用复制算法,收集过程会 STW。它的目标是“高吞吐量”(虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%) ┖ Parallel Old:Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法
G1:G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一。G1 是一款面向服务端应用的垃圾收集器。与其他 GC 收集器相比,G1 具备如下特点:并行与并发、分代收集、空间整合、可预测的停顿。
在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。G1 收集器将整个 Java 堆划分为多个大小相等的独立区域,虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。
类加载机制
类的生命周期
类加载的过程
- 包括了加载、验证、准备、解析、初始化、使用、卸载七个阶段
- 其中验证、准备、解析统称连接
加载
查找并加载类的二进制数据。
加载阶段虚拟机需要完成以下三件事情:
加载 .class 文件的方式:
- 从本地系统中直接加载
- 通过网络下载
- 从 zip, jar 等归档文件中加载
- 从专有数据库中获取
- 将 Java 源文件动态编译为 .class 文件
验证
确保 Class 文件的字节流中的信息不会危害到虚拟机。 在该阶段主要完成以下四种验证:
- 文件格式:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型。
- 元数据: 对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。
- 字节码:整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
- 符号引用:这个动作在后面的解析过程中发生,为了确保解析动作能正确执行。
准备
-
为类的静态变量分配内存(将在方法区中分配)
- 仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
-
将其初始化为默认值,通常情况下是数据类型默认的零值(如 0、0 L、null、false 等),而不是被在 Java 代码中被显式赋予的值
- 假设一个类变量的定义为
public static int value = 3;
那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的 put static 指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
- 假设一个类变量的定义为
解析
将常量池的符号引用替换为直接引用。 解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。
- 符号引用就是一组符号来描述目标,可以是任何字面量。
- 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
为类的静态变量赋予正确的初始值。
在 Java 中对类变量设定初始值有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量设定初始值
JVM 初始化步骤:
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机
- 创建类的实例,也就是 new 的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射 (如
Class.forName("com.pdai.jvm.Test")
) - 初始化某个类的子类,则其父类也会被初始化
- Java 虚拟机启动时被标明为启动类的类(包含
main()
的那个类)
使用
new 出对象程序中使用
卸载
执行垃圾回收
类加载器
通过一个类的全限定名来获取描述此类的二进制字节流,这个动作放到 Java 虚拟机外部去实现,以便让应用程序自由决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”
分类
启动类加载器(Bootstrap ClassLoader):
它用来加载 <JAVA_HOME>\jre\lib
目录中的,或者被 -Xbootclasspath
参数指定的路径中的,并且是虚拟机识别的类库,加载到虚拟机内存中。
扩展类加载器(Extension ClassLoader):
这个加载器由 sun.misc.Launcher$ExtClassLoader
实现,它负责加载 <JAVA_HOME>\lib\ext
目录中的,或者被 java.ext.dirs
系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器(Application ClassLoader):
由 sun.misc.Launcher$AppClassLoader
实现。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
自定义类加载器:
用户根据自定义需求,自由地定制加载的逻辑,继承 AppClassLoader
,重写 findClass()
,遵守双亲委派模型。
获取类加载器
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
//获取扩展类加载器
ClassLoader extendedClassLoader = systemClassLoader.getParent();
//获取根加载器
ClassLoader rootClassLoader = extendedClassLoader.getParent();
JVM 类加载机制
- 全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显式使用另外一个类加载器来载入
- 父类委托:先让父类加载器试图加载该类,如果父类加载器无法加载该类,则尝试从自己的类路径中加载该类
- 缓存机制:缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class。如果缓存区不存在,系统会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。
- 这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效
- 双亲委派机制:当一个类加载器去加载类时,先尝试让父类加载器去加载。如果父类加载器加载不了,再尝试自身加载。双亲委派机制能保证基础类仅加载一次,不会让 JVM 中存在重名的类,保证 Java 程序安全稳定运行。
双亲委派机制过程
- 当
AppClassLoader
加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader
去完成。 - 当
ExtClassLoader
加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader
去完成。 - 如果
BootStrapClassLoader
加载失败(例如在$JAVA_HOME/jre/lib
里未查找到该 class),会使用ExtClassLoader
来尝试加载。 - 若
ExtClassLoader
也加载失败,则会使用AppClassLoader
来加载,如果AppClassLoader
也加载失败,则会抛出异常ClassNotFoundException
。
双亲委派代码分析
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 缓存:首先判断该类型是否已经被加载
Class c = findLoadedClass(name);
if (c == null) {
#### // 如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
// 如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
//如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法 native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。 - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException
异常。
如何实现一个 ClassLoader
继承 ClassLoader
类,然后覆盖 findClass(String name)
方法即可。
ClassLoader#loadClass
使用了模板方法模式,子类只需要实现 findClass
,关心从哪里加载即可。
Reference