文章目录
JVM
是Java Virtual Machine
的缩写,JVM
是一种用于计算设备的规范,它是一个虚构出来的计算机。使用java -version
可以查看当前自己安装的是什么虚拟机:
Java
虚拟机的版本,比如已经被淘汰的Sun Classic VM
,微软公司的Microsoft JVM
等。对于JVM
工作流程图,如下(JavaSE7
版):
- 方法区:是各个内存所共享的内存空间,方法区中主要存放被
JVM
加载的类信息、常量、静态变量、及时编译后的代码等数据。 - 堆区:存放对象实例,几乎所有的对象都在这里分配内存。
- 虚拟机栈:线程私有,生命周期和线程一致。描述的是
Java
方法执行的内存模型:每个方法在执行时都会床创建一个栈帧(Stack Frame
)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。 - 本地方法栈:区别于
Java
虚拟机栈的是,Java
虚拟机栈为虚拟机执行Java
方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native
方法服务。也会有StackOverflowError
和OutOfMemoryError
异常。 - 程序计数器:内存空间小,线程私有。字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。如果线程正在执行一个
Java
方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native
方法,这个计数器的值则为 (Undefined
)。此内存区域是唯一一个在Java
虚拟机规范中没有规定任何OutOfMemoryError
情况的区域。
另:栈空间不需要垃圾回收,因为方法开始执行进行压栈,方法执行完毕就出栈,当方法执行完毕后,栈空间一定是空的,所以不需要垃圾回收。故而GC
操作是对方法区、堆区,其中大部分操作是堆堆区。
参考:JVM:方法区可以GC吗?一文:
0. 方法指令
指令 | 说明 |
---|---|
invokeinterface | 用来调用接口方法 |
invokevirtual | 用来调用对象的实例方法 |
invokestatic | 用来调用静态方法 |
invokespecial | 用来调用一些特殊的实例方法,包括实例初始化方法、私有方法、父类方法 |
1. 类加载器
1.1 类加载过程
类加载的过程为: 加载–>链接(验证–>准备–>解析)–>初始化。
1.1.1. 加载
这个过程主要是通过类的全限定名,获取到字节码文件;然后将这个字节码文件代表的静态存储结构存在方法区,并在堆中生成一个代表此类的 Class
类型的对象,作为访问方法区中“模板”的入口,往后创建对象的时候就按照这个模板创建。
类的加载由类加载器完成,类加载器通常由JVM
提供,这些类加载器也是前面所有程序运行的基础,JVM
提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader
基类来创建自己的类加载器。
类加载器通常无须等到“首次使用”该类时才加载该类,Java
虚拟机规范允许系统预先加载某些类。
1.1.2. 链接
- 验证:主要确保被加载的类的正确性。
- 准备:为类的静态变量分配方法区的内存,并设置默认初始值。比如给
int
类型初始化为0,给引用数据类型,初始化为null
。对于final
类型的数据,必须在程序内给它赋值,系统不会自动初始化,且放入常量池。 - 解析:将类的二进制数据中的符号引用替换成直接引用。
1.1.3. 初始化
将静态变量赋值为初始值,比如 public static int n=5;
完成 给 n
赋值为 5
。
1.2 类加载时机
所有的Java
虚拟机实现必须在每个类或接口被Java
程序“首次主动使用”时才初始化他们,下面六种情况符合首次主动使用要求。
① 创建类的实例
②访问某个类或接口的非常量静态域,或者对该非常量静态域赋值
③ 调用类的静态方法
④反射(如Class.forName(“com.test.Test”)
⑤初始化一个类的子类
⑥Java虚拟机启动时被标明为启动类的类(即文件名和类名相同的那个类 ,包含main
方法)
特别说明:
对于一个final
类型的静态变量,如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。**Java
编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。**比如下面的案例:
public class H {
public static void main(String[] args) {
String name = TestFinal.userName;
System.out.println(name);
}
}
class TestFinal{
public static final String userName = "张三";
static {
System.out.println("Hello!");
}
}
上面程序运行就不会打印Hello!
字符串。可以看下它们的字节码文件:javap -c xxx.class
// javac -p H.class
public class com.weizu.code.H {
public com.weizu.code.H();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #3 // String 张三
2: astore_1
3: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
6: aload_1
7: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: return
}
在H.java
类中,没有指定无参构造方法,就使用invokespecial
来调用父类Object
的构造方法初始化,然后在main
中可以看见字符串的值已经确定为张三
。
如果我们修改下代码:
class TestFinal{
public static final String userName;
static {
userName = "张三";
}
}
继续查看器字节码文件
// javap -c H.class
public class com.weizu.code.H {
public com.weizu.code.H();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field com/weizu/code/TestFinal.userName:Ljava/lang/String;
3: astore_1
4: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
7: aload_1
8: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
11: return
}
在main
中就不再是确定的值,而是使用getstatic
来获取其常量值。由于程序运行正常输出张三
,那么static
代码块一定执行。
如果final
类型的静态Field
的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。
1.3 类加载器
类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class
实例对象。一旦一个类被加载如JVM
中,同一个类就不会被再次载入了。类加载器可以分为四类:
- 启动类加载器(
Bootstrap ClassLoader
);是由C/C++
写的,主要负责加载jre\lib\rt.jar
中的JDK
文件,是所有类加载器的父加载器。 - 扩展类加载器(
Extension ClassLoader
);位于jre\lib\ext
目录下的Jar
包的类,该类加载器在此目录里面查找并加载Java
类。 - 应用程序类加载器(
Application ClassLoader
);就是System
类加载器,比如:System.out.println(ClassLoader.getSystemClassLoader());
结果为:sun.misc.Launcher$AppClassLoader@18b4aac2
,负责从classpath
环境变量中加载相关的类,应用程序类加载器是扩展类加载器的子类。默认情况下使用AppClassLoader
装载应用程序的类 - 自定义类加载器(
User ClassLoader
);
public class H {
public static void main(String[] args) throws Exception {
System.out.println(String.class.getClassLoader()); // 由于启动类加载器是C/C++写的,故而输出为null
System.out.println(ClassLoader.getSystemClassLoader()); // sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(ClassLoader.getSystemClassLoader().getParent()); // sun.misc.Launcher$ExtClassLoader@2626b418
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent()); // 由于启动类加载器是C/C++写的,故而输出为null
}
}
java.lang.String
永远是由根装载器来装载。
Bootstrap
类加载器是用C/C++
语言写的,依java
的观点来看,逻辑上并不存在Bootstrap
类加载器的类实体,所以在java
程序代码里试图打印出其内容时,我们就会看到输出为null
。
它们之间的关系如图:
1.4 双亲委派机制
在类加载的过程中,存在着双亲委派机制,即某个特定的类加载器在接到加载类的请求时,首先将加载任务委托交给父类加载器,父类加载器又将加载任务向上委托,直到根父类加载器,如果最父类加载器可以完成类加载任务,就成功返回,如果不行就向下传递委托任务,由其子类加载器进行加载。
双亲委派机制的优势:采用双亲委派模式的是好处是Java
类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader
再加载一次。其次是考虑到安全因素,保证java
核心库的安全性(例如:如果用户自己写了一个java.lang.String
类就会因为双亲委派机制不能被加载,不会破坏原生的String
类的加载)。
比如:
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println(123);
}
}
代理模式与双亲委派机制相反,代理模式是先自己尝试加载,如果无法加载则向上传递。tomcat
就是代理模式。
1.5 自定义类加载器
自定义类加载器需要继承自ClassLoader
。
2. 类加载机制
JVM
的类加载机制主要有如下3种。
- 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个
Class
时,该Class
所依赖和引用其他Class
也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。 - 双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该
Class
,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。 - 缓存机制。缓存机制将会保证所有加载过的
Class
都会被缓存,当程序中需要使用某个Class
时,类加载器先从缓存区中搜寻该Class
,只有当缓存区中不存在该Class
对象时,系统才会读取该类对应的二进制数据,并将其转换成Class
对象,存入缓冲区中。这就是为很么修改了Class
后,必须重新启动JVM
,程序所做的修改才会生效的原因。
3. Java
内存模型
Java
内存模型(Java Memory Model
,JMM
)用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java
程序在各种平台下都能达到一致的并发效果。定义了线程和主内存之间的抽象关系。
首先看下Java8
的JVM
的内存布局:
方法区在java8
以前是放在JVM
内存中的,由永久代实现,受JVM
内存大小参数的限制,在java8
中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM
参数的限制(当然,如果物理内存被占满了,方法区也会报OOM
),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java
堆中。
4. Java 内存回收机制
不论哪种语言的内存分配方式,都需要返回所分配内存的真实地址,也就是返回一个指针到内存块的首地址。Java
中对象是采用 new
或者反射的方法创建的,这些对象的创建都是在堆(Heap
)中分配的,所有对象的回收都是由 Java
虚拟机通过垃圾回收机制完成的。GC
为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控,Java
会使用有向图的方法进行管理内存,实时监控对象是否可以达到,如果不可到达,则就将其回收,这样也可以消除引用循环的问题。在 Java
语言中,判断一个内存空间是否符合垃圾收集标准有两个:一个是给对象赋予了空值 null
,以下再没有调用过;另一个是给对象赋予了新值,这样重新分配了内存空间。
4.1 对象存活判断
在进行内存回收之前要做的事情就是判断那些对象是‘死’的,哪些是‘活’的。有两种方法:
- 引用计数法:每个对象有一个引用计数器,当对象被引用一次则计数器加
1
,当对象引用失效一次则计数器减1
,对于计数器为0
的对象意味着是垃圾对象,可以被GC
回收。优点:实现逻辑简单;缺点:无法解决循环引用问题;目前没有在使用。 - 可达性分析算法:从
GC Roots
作为起点开始搜索,那么整个连通图中的对象便都是活对象,对于GC Roots
无法到达的对象便成了垃圾回收的对象,随时可被GC
回收。
哪些对象可以作为 GC Root
?
- 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)
- 方法区中的类静态属性引用的对象。
- 方法区中常量引用的对象
- 本地方法栈中 N( Native 方法)引用的对象
4.2 新生代和老年代的区别
新生代和老年代是针对于分代收集算法来定义的,新生代又分为 Eden
和 Survivor
两个区。加上老年代就这三个区。
数据会首先分配到 Eden
区 当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的 java
对象)。),当 Eden
没有足够空间的时候就会 触发 jvm
发起一次 Minor GC
。如果对象经过一次 Minor GC
还存活,并且又能被 Survivor
空间接受,那么将被移动到 Survivor
空 间当中。并将其年龄设为 1
,对象在 Survivor
每熬过一次 Minor GC
,年龄就加 1
,当年龄达到一定的程度(默认为 15
)时,就会被晋升到老年代 中了,当然晋升老年代的年龄是可以设置的(-XX:maxtenuringThreshold
)。如果老年代满了就执行:Full GC
因为不经常执行,因此采用了 Mark-Compact
算法清理。
Survivor 区域对象晋升到老年代有两种情况:
- 一种是给每个对象定义一个对象计数器,如果对象在
Eden
区域出生,并且经过了第一次GC
,那么就将他的年龄设置为 1,在Survivor
区域的对象每熬过一次 GC,年龄计数器加一,等到到达默认值15
时,就会被移动到老年代中,默认值可以通过-XX:MaxTenuringThreshold
来设置。 - 另外一种情况是如果
JVM
发现Survivor
区域中的相同年龄的对象占到所有对象的一半以上时,就会将大于这个年龄的对象移动到老年代,在这批对象在统计后发现可以晋升到老年代,但是发现老年代没有足够的空间来放置这些对象,这就会引起Full GC
。
新生代回收的判断条件是新生代内存不足时候。新生代主要是用来存放新生的对象,会频繁创建对象,所有垃圾收集会频繁进行回收。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代回收 Full GC
触发条件。Full GC
相对于 Minor GC
来说,停止用户线程的时间过长,至少慢 10
倍以上,所以要尽量避免。通常在下面几种情况下会出发Full GC
。
System.gc()
方法的调用。该方法会建议JVM
进行Full GC
,但是注意这只是建议,JVM
执行不执行是另外一回事儿,不过在大多数情况下会增加Full GC
的次数,导致系统性能下降,一般建议不要手动进行此方法的调用。- 老年代(
Tenured Gen
)空间不足。在Survivor
区域的对象满足晋升到老年代的条件时,晋升进入老年代的对象大小大于老年代的可用内存,这个时候会触发Full GC
。 Metaspace
区内存达到阈值。从JDK8
开始,永久代(PermGen
)的概念被废弃掉了,取而代之的是一个称为Metaspace
的存储空间。Metaspace 使用的是本地内存,而不是堆内存,也就是说在默认情况下Metaspace
的大小只与本地内存大小有关。-XX:MetaspaceSize=21810376B
(约为20.8MB
)超过这个值就会引发Full GC
,这个值不是固定的,是会随着JVM
的运行进行动态调整的。- 堆中产生大对象超过阈值。这个参数可以通过
-XX:PretenureSizeThreshold
进行设定,大对象或者长期存活的对象进入老年代,典型的大对象就是很长的字符串或者数组,它们在被创建后会直接进入老年代,虽然可能新生代中的 Eden 区域可以放置这个对象,在要放置的时候JVM
如果发现老年代的空间不足时,会触发GC
。 - 老年代连续空间不足。
VM
如果判断老年代没有做足够的连续空间来放置大对象,那么就会引起Full GC
。 CMS GC
时出现promotion failed
和concurrent mode failure
。CMS
即:concurrent marks sweep
并行标记清除垃圾回收机制。cms
只会回收老年代和元数据区。是一种预处理垃圾回收器,它不能等到old
内存用尽时回收,需要在内存用尽前,完成回收操作,否则会导致并发回收失败。在Minor GC
过程中,Survivor Unused 可能不足以容纳Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion)。 这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步, 如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。在 CMS 启动过程中,新生代提升速度过快,老年代收集速度赶不上新生代提升速度。在 CMS 启动过程中,老年代碎片化严重,无法容纳新生代提升上来的大对象,这是因为CMS 采用标记清理,会产生连续空间不足的情况,这也是 CMS的缺点。
总结:其实堆内存的 Full GC
一般都是两个原因引起的,要么是老年代内存过小,要么是
老年代连续内存过小。无非是这两点,而元数据区 Metaspace
引发的 Full GC
可能是阈值引起的。
新生代使用的算法:使用复制算法,复制算法将堆中可用的新生代内存按容量划分成大小相等的两块内存区域,每次只使用其中的一块区域。当其中一块内存区域需要进行垃圾回收时,会将此区域内还存活着的对象复制到另一块上面,然后再把此内存区域一次性清理掉。
老年代使用的算法:
- 标记清除法(mark sweep)。标记清除算法是最基础的回收算法,分为标记和清除两个部分:首先标记出所有需要回收的对象,这一过程在可达性分析过程中进行。在标记完之后统一回收所有被标记的对象。优点:速度较快;缺点:标记和清除这两个过程的效率不高;会造成内存碎片,导致在程序运行过程中需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
- 标记整理(mark compact)算法。标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。优点:没有内存碎片;缺点:速度慢。
分代垃圾回收:
根据各个年代的特点采取最适当的收集算法。
- 在新生代中,每次垃圾收集时候都发现有大批对象死去,只有少量存活,那就选用复制算法。只需要付出少量存活对象的复制成本就可以完成收集。
- 老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须用标记-清除或者标记-整理。
- 对象首先分配在伊甸园区域。
- 新生代空间不足时,触发
minor gc
,伊甸园 和from
存活的对象使用copy
复制到to
中,存活的对象年龄加1
并且交换from to
。 minor gc
会引发stop the word
,暂停其它用户线程,等垃圾回收结束,用户线程才恢复运行。- 当对象寿命超过阈值时,会晋升至老年代,最大寿命
15
(4bit)。 - 当老年代空间不足,会先尝试触发
minor gc
,如果之后空间仍不足,那么触发full gc
,停止用户线程的时间过更长。
5. 垃圾收集器
串行(Serial)垃圾收集器:最基础的收集器,使用复制算法、单线程工作,只用一个处理器或一条线程完成垃圾收集,进行垃圾收集时必须暂停其他所有工作线程。
ParNew:Serial 的多线程版本,除了使用多线程进行垃圾收集外其余行为完全一致。
CMS 回收:基于标记-清除算法,整个过程分为四个步骤:
(1) 初始标记 (2) 并发标记 (3)重新标记 (4)并发清理初始标记和重新标记需要 STW(Stop The World,系统停顿)
(1)初始标记仅是标记 GC Roots 能直接关联的对象,速度很快。
(2)并发标记从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长但不需要停顿用户线程。
(3)重新标记则是为了修正并发标记期间因用户程序运作而导致标记产生变动的那部分记录。
(4)并发清除清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与用户线程并发
优点:并发收集,低停顿。
缺点:CMS 收集器对 CPU 资源非常敏感,在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程使应用程序变慢,总吞吐量会降低。
CMS 处理器无法处理浮动垃圾,CMS 在并发清理阶段线程还在运行, 伴随着程序的运行自然也会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS 无法再当次过程中处理,所以只有等到下次 gc 时候在清理掉,这一部分垃圾就称作“浮动垃圾”。
CMS 是基于“标记–清除”算法实现的,所以在收集结束的时候会有大量的空间碎片产生。空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc。
G1 面向服务端应用的垃圾收集器。
与 CMS 的“标记–清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
G1 之前的收集器,垃圾收集目标要么是整个新生代,要么是整个老年代或整个堆。而 G1 可面向堆任何部分来组成回收集进行回收,衡量标准不再是分代,而是哪块内存中存放的垃圾数量最多,回收受益最大。
跟踪各 Region 里垃圾的价值,价值即回收所获空间大小以及回收所需时间的经验值,在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值最大的Region。这种方式保证了 G1 在有限时间内获取尽可能高的收集效率。
Thanks