(JVM)JVM笔记汇总
JVM笔记
JVM启动流程
1. 配置JVM装载环境
JVM的环境创建包括两部分: JVM.dll文件的查找和装载
- JVM.dll文件的查找:首先查找系统环境变量中的PATH路径中有没有JVM的相关配置,如果没有,则继续查找环境变量中的JAVA_HOME路径中的JVM.dll。若在JAVA_HOME中也找不到,就会在注册表中查找,如果连注册表里都找不到,JVM就无法启动了。
- JVM.dll文件的装载:LoadLibrary方法装载JVM.dll动态连接库。然后把JVM.dll文件中定义的函数JNI_CreateJavaVM和JNI_GetDefaultJavaVMInitArgs绑定到InvocationFunctions变量的CreateJavaVM和GetDefaultJavaVMInitArgs函数指针变量上;
2. 解析虚拟机参数
将JVM环境装入内存后,接下来要做的就是解析JVM的参数
- JVM在启动时,会将传递给它的命令行参数存储到一个字符串数组,之后JVM会遍历该数组,解析每个参数
JVMM会根据参数的格式和语义进行解析和处理,常见的虚拟机参数包括
- 标准虚拟机参数: -Xms: 堆的最小值, -Xmx堆的最大值, -Xmn: 新生代的大小, Xss: 每个线程分配的内存大小
- 启动类参数: 如 -classpath: 启动类路径, -D: 系统属性 等
- 扩展参数L, 如 -XX: 用于JVM的非标选项, 如性能调优、垃圾回收等
解析完成后,JVM会对传入的参数进行可行性验证,如果验证可行,则JVM会根据参数的含义和配置,进行对应的设置操作,反之则会抛出异常信息
3. 设置线程栈大小
在解析虚拟机参数的过程中,如果设置了Xss,就会按照Xss配置IDE大小来设置线程栈大小,若没有传入Xss,则根据操作系统的不同,读取对应系统的配置文件,来对线程栈大小进行设置。
4. 执行程序的main方法
新建JVM实例
执行
ContinueInNewThread
函数,创建一个新的线程来执行新建JVM实例的操作寻找main函数并且执行
接着会进入
ContinueInNewThread0
函数,其将JavaMain
作为参数传入。这个函数将会在新的线程中执行JavaMain函数载入主类后获取主类中的主方法并执行,一旦主方法开始执行,Java程序就会开始快速运转了,直到走到主方法的最后一行才会return返回
图示整个执行过程
内存管理
在Java中,不允许使用指针等工具来直接操作物理内存,对于内存的操作时由JVM来代我们进行处理的,虽然这样让Java的内存管理变得简单,但是这样降低了Java操作内存的效率,同时如果JVM在内存操作时出现了问题,难以找到出问题的点。
内存区域划分
JVM将内存划分为如下的区域
内存一共分为:方法区、堆区、虚拟机栈、本地方法栈和程序计数器五个区域。
其中方法区和堆区是由所有线程共享的区域,其随着虚拟机的创建而创建,随着虚拟机的销毁而销毁。
对于虚拟机栈、本地方法栈和程序计数器是线程之间相互隔离的,每个线程都有自己的这三块区域,其生命周期与线程的生命周期保持一致。
程序计数器
JVM中的程序计数器PC和我们学计组的PC差不多是一个概念,其目的就是让Java虚拟机像物理机那样执行程序。
不同于8086CPU中PC记录下一条指令的地址,JVM中的PC可以看做是当前线程所执行的字节码的行号的指示器,每当一条指令执行完毕,就会读取下一条应该执行的指令的行号并存入PC中
Java的多线程是依靠时间片轮转算法来进行的,每个线程在其所占有的时间片终结时,会保存当前执行位置到PC中,当该线程再次抢占到CPU后,就从PC中读取断点,继续执行
时间片轮转算法:在多线程环境中,时间片轮转算法将每个线程分配一个固定大小的时间片,然后按照轮转的顺序让每个线程执行一个时间片的时间。当一个线程的时间片用完后,调度器会暂停该线程并将其放到队列的末尾,然后选择下一个线程执行。
时间片轮转算法的优点是公平性,每个线程都有机会执行,并且每个线程都能获得相同的时间片。这样可以确保所有线程都能获得公平的CPU时间,避免某个线程长时间占用CPU资源而导致其他线程无法执行的情况。
虚拟机栈
虚拟机栈是线程独占的。每当Java程序中的方法被执行时,JVM就会同步创建为一个栈帧(可以理解为栈里的一个元素),帧栈中包含了当前方法的一些基本信息(如局部变量表、操作数栈、动态链接和方法出口等)
局部变量表就是方法内部需要使用到的局部变量;操作数栈就是字节码执行时需要使用到的栈;每个栈帧还保存了一个指向当前方法所在类的运行时的常量池,这样在当前方法需要调用其他本类中的方法时,就可以通过运行时常量池找到对应方法的符号引用,然后将符号引用转换为直接引用,这样就能调用对应方法,这就是动态链接。最后,方法出口,顾名思义,就是方法结束的条件。
符号引用是程序设计中一个重要的概念,通常用于指代一个符号(如变量、函数、类等)的标识符,而不是直接引用其具体的实体。
在Java中,符号引用是一种抽象的概念,它表示对某个符号的引用或使用。通过符号引用,编译器或解释器可以在编译或运行时解析符号,并找到对应的具体实体(即它的对应地址),即转化为直接引用。
其实虚拟机栈就是用来记录程序执行过程的,比如main中调用了b,此时虚拟机栈中已经有了b了,然后这是将b加入虚拟机栈中执行,若b执行完成,则b出栈,最后main也出栈,这样就完成了程序的执行
本地方法栈
本地方法栈与虚拟机栈差不多,这里直接引用GPT写的介绍
本地方法栈是JVM运行时数据区域之一,用于支持执行本地方法的调用和执行。在Java程序中,本地方法是使用其他语言(如C、C++)编写的方法,通过JNI与Java代码进行交互。本地方法通常用于实现与底层系统相关的功能,如访问硬件设备、调用操作系统接口等。
本地方法栈与Java虚拟机栈类似,但它们的作用不同。Java虚拟机栈用于支持Java方法的调用和执行,而本地方法栈用于支持本地方法的调用和执行。
堆
堆是整个Java程序共享的区域,也是JVM内最大的一块内存空间。它的作用就是存放和管理对象数组,垃圾回收机制就是作用于这一块内存区域。
方法区
方法区也是整个Java程序共享的区域,它用于存储所有的类、常量、静态变量、动态编译缓存等数据,可以大致分为两部分,一个是类信息表,一个是运行常量池。
类信息表用于存放当前引用程序所加载的所有类的信息,在运行时产生的新的类的数据,也会存入类信息表中。
运行时常量池会存储所有在编译时生成的常量池数据。
具体的例子
这里直接引用白马讲师的文档内容
其实我们的String类正是利用了常量池进行优化,这里我们编写一个测试用例:
1
2
3
4
5
6
7 >public static void main(String[] args) {
>String str1 = new String("abc");
>String str2 = new String("abc");
>System.out.println(str1 == str2);
>System.out.println(str1.equals(str2));
>}得到的结果也是显而易见的,由于
str1
和str2
是单独创建的两个对象,那么这两个对象实际上会在堆中存放,保存在不同的地址:
所以当我们使用
==
判断时,得到的结果false
,而使用equals
时因为比较的是值,所以得到true
。现在我们来稍微修改一下:
1
2
3
4
5
6
7 >public static void main(String[] args) {
>String str1 = "abc";
>String str2 = "abc";
>System.out.println(str1 == str2);
>System.out.println(str1.equals(str2));
>}现在我们没有使用new的形式,而是直接使用双引号创建,那么这时得到的结果就变成了两个
true
,这是为什么呢?这其实是因为我们直接使用双引号赋值,会先在常量池中查找是否存在相同的字符串,若存在,则将引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将引用指向该字符串:
实际上两次调用String类的
intern()
方法,和上面的效果差不多,也是第一次调用会将堆中字符串复制并放入常量池中,第二次通过此方法获取字符串时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址:
1
2
3
4
5
6
7
8 >public static void main(String[] args) {
>//不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了
>String str1 = new String("ab")+new String("c");
>String str2 = new String("ab")+new String("c");
>System.out.println(str1.intern() == str2.intern());
>System.out.println(str1.equals(str2));
>}
所以上述结果中得到的依然是两个
true
。在JDK1.7之后,稍微有一些区别,在调用intern()
方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用:
1
2
3
4
5 >public static void main(String[] args) {
//不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了
>String str1 = new String("ab")+new String("c");
>System.out.println(str1.intern() == str1);
>}
1
2
3
4
5
6
7 >public static void main(String[] args) {
>String str1 = new String("ab")+new String("c");
>String str2 = new String("ab")+new String("c");
>System.out.println(str1 == str1.intern());
>System.out.println(str2.intern() == str1);
>}所以最后我们会发现,
str1.intern()
和str1
都是同一个对象,结果为true
。值得注意的是,在JDK7之后,字符串常量池从方法区移动到了堆中。
最后我们再来进行一个总结,各个内存区域的用途:
- (线程独有)程序计数器:保存当前程序的执行位置。
- (线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
- (线程独有)本地方法栈:同上,作用与本地方法。
- 堆:所有的对象和数组都在这里保存。
- 方法区:类信息、即时编译器的代码缓存、运行时常量池。
当然,这些内存区域划分仅仅是概念上的,具体的实现过程我们后面还会提到。
爆内存和爆栈
实际上,在Java程序运行时,内存容量不可能是无限制的,当我们的对象创建过多或是数组容量过大时,就会导致我们的堆内存不足以存放更多新的对象或是数组,这时就会出现错误,比如:
1
2
3 >public static void main(String[] args) {
int[] a = new int[Integer.MAX_VALUE];
>}这里我们申请了一个容量为21亿多的int型数组,显然,如此之大的数组不可能放在我们的堆内存中,所以程序运行时就会这样:
1
2 >Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at com.test.Main.main(Main.java:5)这里得到了一个
OutOfMemoryError
错误,也就是我们常说的内存溢出错误。我们可以通过参数来控制堆内存的最大值和最小值:
1 >-Xms最小值 -Xmx最大值比如我们现在限制堆内存为固定值1M大小,并且在抛出内存溢出异常时保存当前的内存堆转储快照:
注意堆内存不要设置太小,不然连虚拟机都不足以启动,接着我们编写一个一定会导致内存溢出的程序:
1
2
3
4
5
6
7
8
9
10 >public class Main {
public static void main(String[] args) {
List<Test> list = new ArrayList<>();
while (true){
list.add(new Test()); //无限创建Test对象并丢进List中
}
}
static class Test{ }
>}在程序运行之后:
1
2
3
4
5
6
7
8
9
10
11 >java.lang.OutOfMemoryError: Java heap space
>Dumping heap to java_pid35172.hprof ...
>Heap dump file created [12895344 bytes in 0.028 secs]
>Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:267)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
at java.util.ArrayList.add(ArrayList.java:464)
at com.test.Main.main(Main.java:10)可以看到错误出现原因正是
Java heap space
,也就是堆内存满了,并且根据我们设定的VM参数,堆内存保存了快照信息。我们可以在IDEA内置的Profiler中进行查看:
可以很明显地看到,在创建了360146个Test对象之后,堆内存蚌埠住了,于是就抛出了内存溢出错误。
我们接着来看栈溢出,我们知道,虚拟机栈会在方法调用时插入栈帧,那么,设想如果出现无限递归的情况呢?
1
2
3
4
5
6
7
8
9 >public class Main {
public static void main(String[] args) {
test();
}
public static void test(){
test();
}
>}这很明显是一个永无休止的程序,并且会不断继续向下调用test方法本身,那么按照我们之前的逻辑推导,无限地插入栈帧那么一定会将虚拟机栈塞满,所以,当栈的深度已经不足以继续插入栈帧时,就会这样:
1
2
3
4
5
6
7
8 >Exception in thread "main" java.lang.StackOverflowError
at com.test.Main.test(Main.java:12)
at com.test.Main.test(Main.java:12)
at com.test.Main.test(Main.java:12)
at com.test.Main.test(Main.java:12)
at com.test.Main.test(Main.java:12)
at com.test.Main.test(Main.java:12)
....以下省略很多行这也是我们常说的栈溢出,它和堆溢出比较类似,也是由于容纳不下才导致的,我们可以使用
-Xss
来设定栈容量。申请堆外内存
除了堆内存可以存放对象数据以外,我们也可以申请堆外内存(直接内存),也就是不受JVM管控的内存区域,这部分区域的内存需要我们自行去申请和释放,实际上本质就是JVM通过C/C++调用
malloc
函数申请的内存,当然得我们自己去释放了。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出OutOfMemoryError
异常。这里我们需要提到一个堆外内存操作类:
Unsafe
,就像它的名字一样,虽然Java提供堆外内存的操作类,但是实际上它是不安全的,只有你完全了解底层原理并且能够合理控制堆外内存,才能安全地使用堆外内存。注意这个类不让我们new,也没有直接获取方式(压根就没想让我们用):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 >public final class Unsafe {
private static native void registerNatives();
static {
registerNatives();
sun.reflect.Reflection.registerMethodsToFilter(Unsafe.class, "getUnsafe");
}
private Unsafe() {}
private static final Unsafe theUnsafe = new Unsafe();
public static Unsafe getUnsafe() {
Class<?> caller = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(caller.getClassLoader()))
throw new SecurityException("Unsafe"); //不是JDK的类,不让用。
return theUnsafe;
}所以我们这里就通过反射给他giao出来:
1
2
3
4
5
6 >public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
>}成功拿到Unsafe类之后,我们就可以开始申请堆外内存了,比如我们现在想要申请一个int大小的内存空间,并在此空间中存放一个int类型的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 >public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
//申请4字节大小的内存空间,并得到对应位置的地址
long address = unsafe.allocateMemory(4);
//在对应的地址上设定int的值
unsafe.putInt(address, 6666666);
//获取对应地址上的Int型数值
System.out.println(unsafe.getInt(address));
//释放申请到的内容
unsafe.freeMemory(address);
//由于内存已经释放,这时数据就没了
System.out.println(unsafe.getInt(address));
>}我们可以来看一下
allocateMemory
底层是如何调用的,这是一个native方法,我们来看C++源码:
1
2
3
4
5
6
7
8 >UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
size_t sz = (size_t)size;
sz = align_up(sz, HeapWordSize);
void* x = os::malloc(sz, mtOther); //这里调用了os::malloc方法
return addr_to_java(x);
>} UNSAFE_END接着来看:
1
2
3
4
5
6
7
8
9
10
11
12 >void* os::malloc(size_t size, MEMFLAGS flags) {
return os::malloc(size, flags, CALLER_PC);
>}
>void* os::malloc(size_t size, MEMFLAGS memflags, const NativeCallStack& stack) {
...
u_char* ptr;
ptr = (u_char*)::malloc(alloc_size); //调用C++标准库函数 malloc(size)
....
// we do not track guard memory
return MemTracker::record_malloc((address)ptr, size, memflags, stack, level);
>}所以,我们上面的Java代码转换为C代码,差不多就是这个意思:
1
2
3
4
5
6
7
8
9
10 >
>
>int main(){
int * a = malloc(sizeof(int));
*a = 6666666;
printf("%d\n", *a);
free(a);
printf("%d\n", *a);
>}所以说,直接内存实际上就是JVM申请的一块额外的内存空间,但是它并不在受管控的几种内存空间中,当然这些内存依然属于是JVM的,由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存来得快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度,如nio框架。
垃圾回收机制
Java程序不要求我们手动操作内存,而是由JVM为我们提供了一套全自动的内存管理机制,JVM会自动判断对象的回收时机
对象存活判定算法
既然要回收内存空间,就需要判断对象在什么时候需要被JVM回收,下面介绍一些常用的垃圾回收算法
引用计数法
通常在操作对象时,我们操作的是对象的引用,这样就会创建一个引用变量
1 | String str = "This is a String."; //这里的str就是一个引用对象,我们通过引用对象来操作对象 |
我们Java程序员通常是通过引用变量来操作对象的,这样如果一个对象还有操作价值,我们就会用它的引用变量来操作它;因此,我们可以通过引用变量的计数来判断一个对象是否还在被使用
- JVM会为每个对象都创建一个引用计数器,存放该对象被引用的次数
- 每当有新的引用变量指向该对象时,就会让 该对象的引用对象 + 1
- 当引用对象指向其他地方或设为null时,会让 该对象的引用对象 - 1
- 当该对象的引用计数为0时,就可以看做本对象已经不再被使用了
这样会存在两个对象相互引用的问题,下面给出一个例子
对象a中有一个指针a.p指向b,同样地,b中也有一个指针b.p指向a。这样ab之间就形成了互相引用;如果此时我们使a,b都为null,这是显然显然已经无法使用到a,b中的指针了,但这个指针仍然指向原来的地址,其指向对象的计数器不为0,就仍然不会被清理;
所以说,引用计数法不是最好的。
可达性分析法
目前的主流编程语言,一般都会使用可达性分析法来检测对象的存活情况,它采用了类似树结构的搜索机制
首先每个对象的引用都有机会成为该对象的树的根节点,根节点的选拔标准如下
- 位于虚拟机栈的帧栈中的本地变量表中所应用到的对象(理解为方法中的局部变量),同样也包含JNI引用的对象
- 类的静态成员引用的对象
- 方法区中常量池里引用的对象,例如上面演示的String类型的对象
- 带有锁的对象(synchronized)
- 虚拟机内部需要用到的对象
当已经存在的根节点不满足其存在条件时,根节点与对象之间的连接将被断开,如果断开后的对象没有被任何根节点所引用,说明该对象不再被使用
所以,对与可达性分析算法,如果某个对象无法到达任何根节点,则说明此对象是不可能再被使用的。
最终判定
虽然上面介绍的算法能基本判定哪些对象是可以被回收的,但是不代表被判定为可回收的对象就一定会被回收
对于java的Object类中的finalize()方法,其会在被垃圾回收期回收之前调用;如果执行finalize()时,当前对象若成功建立了可达性树,则该对象不会被垃圾回收期回收
这里给出一个例子
1 | public class Main { |
同时,同一个对象的finalize()
方法只会有一次调用机会,也就是说,如果我们连续两次这样操作,那么第二次,对象必定被回收:
1 | public static void main(String[] args) throws InterruptedException { |
需要注意的是,finalize()
方法也并不是专门防止对象被回收的,我们可以使用它来释放一些程序使用中的资源等。
最后,总结成一张图:
垃圾回收算法
分代收集机制
我们在判断对象是否需要被回收时,如果采用逐一判断的方法,会导致效率的降低。所以就产生了分代收集机制,它汤我们可以对堆中的对象进行分代管理。
对于有些经过多次判断都被未被判定为需要被回收的对象,我们可以将他们放在一起,作为一代,并使垃圾收集器减少扫描此区域对象的频率,这样就可以提高垃圾回收的效率
JVM将堆内存划分为新生代、老年代和永久代(这里的永久带是HotSpot虚拟机特有的概念)。不同的分代内存回收机制也存在一些不同点;
以HotSpot虚拟机为例,新生代被划为三块,一块较大的Eden空间和两块较小的Survivor空间,默认大小比为8:1:1;而老年代被扫描的评率比较低;永久代则一般存放类信息(方法区的实现)
运行方式
- 对于所有新创建的对象,都会进入新生带的Eden区(Eden即伊甸园,初生的对象被丢进伊甸园,合理),对于新生代内的垃圾扫描,会先扫Eden区
- 紧接着,在经过一次垃圾回收后,未被清理的Eden内的对象会进入到Servivor区内的From区域。最后From区域和To区域会进行一次互换
- 接着进行下一次扫描,先重复上面的操作,但是在将Eden的对象防区From区后,会将To内的对象进行一次年龄和判定(年龄指进过垃圾清理的轮次),若有对象的年龄大于15(默认值),则会被放入老年代,否则就继续移动到From区重复交换判断
- 以此类推,循环往复
垃圾收集也可以有不同的种类
Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。
触发条件:新生代的Eden区容量已满时。
Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。
Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。
触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间
触发条件2:Minor GC后存活的对象超过了老年代剩余空间
触发条件3:永久代内存不足(JDK8之前)
触发条件4:手动调用
System.gc()
方法
空间分配担保
现在假设一种极端情况,在经过了一次GC清理后,Eden区仍有大量对象,此时Survivor区也容不下这么多对象了,现在该怎么办?
现在就可以利用空间分配担保机制,将Survivor区所无法容纳对象直接送到老年代,让老年代进行分配担保;即当新生代无法容纳更多对象时,可以把新生代中的对象移动到老年代中,这样新生代就腾出了空间来容纳更多对象
这里又有一个新问题,你既然要把对象从新生代直接扔到老年代,如果老年代也装不下呢?这时就需要在装入老年代前进行一次判断,判断之前的每次垃圾回收时进入老年代的平均大小是否小于当前老年代的剩余空间。如果小于,也不一定放得下,因为是平均大小;如果大于,就会先进行一次大规模的垃圾回收,若回收后老年代还是装不下,则抛出OOM错误。
下面介绍几种算法,来实现具体的回收过程
标记-清除算法
首先标记处所有需要回收的对象,然后直接对他们进行回收。(也可以反着来,标记不需要回收的对象)
本算法显而易见的非常简单,但是这样清理,会导致大量的内存碎片,造成内存空间的利用率降低
标记-复制算法
上面直接删除的算法会造成空间利用率低,我们换一种思路接着来
首先将内存对半分,右侧区域暂时置空,这是扫描左侧区域,将不需要清理的对象复制到右侧区域,然后将左侧对象全部清空,然后重复这个步骤即可,这样就避免了内存的空间碎片
本算法适用于新生代(因为新生代的对象会被频繁回收)
标记-整理算法
上面的标记-复制算法适用于新生代,那老年代呢?既然都到老年代了,都是些腿脚不便不会频繁移动的老登了,自然就最好不要把他们左右折腾,这时就产生了标记-整理算法
我们首先将所有待回收的对象整齐地排列在一段内存空间中,并对需要回收的对象进行标记,然后将所有需要回收的对象丢到尾部,这样这段空间的前半部分是无需回收的对象,后半部分是需要回收的对象,直接将后半部分清理就行了。
显而易见的,本算法的效率低,并且因为涉及到内存位置的移动,程序需要进行暂停
所以在实际应用中,我们常将标记-整理算法和标记-清理算法混合使用,在内存空间碎片不算太多时,可以使用标记清除法,当碎片比较多了,我们就使用一次标记整理法
垃圾收集器实现
Serial收集器
在JDK1.3.1之前,其是JVM新生代区域垃圾收集器的唯一选择。他是单线程的,在他工作时,需要暂停所有线程,直到他干完活。对于新生代它使用标记-复制,对于老年代他采用标记-整理算法
优点:设计简单高效
缺点: 在清理时会暂停线程
ParNew收集器
ParNew对Serial进行了升级,相当于是它的多线程版本
Parallel Scavenge/Parallel Old收集器
Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现
与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。
目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。
CMS收集器
在JDK1.5中,出现了CMS收集器,它支持并发收集垃圾,第一次实现了垃圾收集器与用户线程同时工作
它主要采用标记清除算法
它的收集过程分为以下四个阶段
- 初始标记(需要暂停用户线程):根据之前提到过得GC树根,找到他的节点对象,并标记他们
- 并发标记:遍历上面标记的对象
- 重新标记(需要暂停用户线程):由于并发标记阶段可能某些用户线程会产生变化,这里需要再次标记一遍
- 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。
优点:支持并发
缺点:由于采用标记清除法,会产生内存碎片; 因为和用户线程并发执行,会降低用户线程执行速度
CMS已经很好了?下面这位G1更是重量级
Garbage First (G1) 收集器
G1也是一款划时代的收集器,在JDK7时推出,主要面向服务器端。
G1将Java堆区划分为2048个大小相同的独立Regin块,每个块的大小被控制在1~32MB,且都为2的N次幂,大小在JVM生命周期内不会改变。
分块了有什么用呢?G1通过分块,让每一个块都可以自由的扮演任意角色,收集器会根据块的角色采取不同的回收策略。此外,G1还存在一个Humongous区域,它专门用于存大对象(大小超过块的一半)
它的回收过程与CMS大体类似:
分为以下四个步骤:
- 初始标记(暂停用户线程):根据GC树根,找到他们的直接关联的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
元空间
JDK8之前,Hotspot虚拟机的方法区实际上是永久代实现的。
在JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了全新的元空间。类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。
因此在JDK8时直接将本地内存作为元空间(Metaspace)的区域,物理内存有多大,元空间内存就可以有多大,这样永久代的空间分配问题就讲解了,所以最终它变成了这样:
引用类型
强引用
强引用是Java的默认引用,对任何一个对象的复制对象都会产生其强引用,比如Object o = new Object()
的操作,obj就是new Object()的强引用
特点: 只要强引用存在,被引的对象就不会被垃圾回收
软引用
相比于强引用,在JVM内存不足时,被软引用的对象才会被回收,当内存充足时,不会轻易的回收被软引用的对象
可以通过以下的方法来创建一个软引用
1 | public class Main { |
弱引用
相比于上面两种引用,在垃圾回收时,弱引用不管当前内存是否充裕,都会被回收
可以通过以下的方法来创建一个弱引用:
1 | public class Main { |
虚引用(鬼引用)
虚引用就可以看做没有引用,随时都可能被回收,它本身就不算个引用。
最后,Java中4种引用的级别由高到低依次为: 强引用 > 软引用 > 弱引用 > 虚引用