🌸个人主页:https://blog.csdn.net/2301_80050796?spm=1000.2115.3001.5343
🏵️热门专栏:🍕 Collection与数据结构 (92平均质量分)https://blog.csdn.net/2301_80050796/category_12621348.html?spm=1001.2014.3001.5482
🧀线程与网络(96平均质量分) https://blog.csdn.net/2301_80050796/category_12643370.html?spm=1001.2014.3001.5482
🍭MySql数据库(93平均质量分)https://blog.csdn.net/2301_80050796/category_12629890.html?spm=1001.2014.3001.5482
🍬算法(97平均质量分)https://blog.csdn.net/2301_80050796/category_12676091.html?spm=1001.2014.3001.5482
感谢点赞与关注~~~
在这里插入图片描述

什么是Java虚拟机?Java虚拟机是一个可以执行Java字节码文件的虚拟机进程.

目前JVM的实现方式使用的是HotSpot,目前HotSpot占用绝对的市场地位,称霸武林.
不管是现在仍在广泛使用JDK6,还是使用比较多的JDK8中,默认的虚拟机都是HotSpot;
Sun/Oracle JDK和OpenJDK的默认虚拟机。从服务器、桌面到移动端、嵌入式都有应用。
名称中的HotSpot指的就是它的热点代码探测技术。它能通过计数器找到最具编译价值的代码,触发即时编译(JIT)或栈上替换;通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

1. JVM内存划分

JVM本质上是一个Java进程,这个进程一旦跑起来,就会从操作系统中申请一大块内存空间.那么JVM接下来就要对这一大块空间进行划分,每个区域都有不同的功能.划分出来的也叫JVM运行时数据区或者也叫内存布局.总体上分为线程私有区和线程共享区两个大区.
在这里插入图片描述

1.1 堆区

  • 堆区是各个数据区中最大的区域.
  • 他的作用是:程序中创建的所有对象都保存在堆中.
  • 这个区域是所有线程共享的.
  • 堆中分为两个区域,一个是新生代,一个是老年代,新生代中还有三个区域,一个是伊甸区(Eden),两个生存区(s1/s0).具体这几个区域中都存放什么样的对象,我们后面讲到垃圾回收机制的时候再说.
  • 我们常见的JVM参数设置-Xms10m最小启动内存是针对堆的,-Xmx10m最大运行内存也是针对堆的.
    在这里插入图片描述

对象也不一定就分配在堆中,如果开启了逃逸分析,有时候对象会被创建在虚拟机栈中.下面我们介绍什么是逃逸分析算法:

  1. 概念: 逃逸分析是一种可以有效减少Java程序中同步负载和内存对分配压力的跨函数全局数据流分析算法,通过逃逸分析,能够分析出一个新对象的引用和使用范围,从而决定是否要将这个对象分配到堆上.
  2. 对象什么时候分配在栈上,什么时候分配在堆上: 逃逸分析是指分析一个对象的引用动态范围的方法,当变量在方法中分配之后,其引用有可能被返回或者被全局引用,这样就会被其他的方法或者线程所引用.这种现象就称为引用的逃逸.如果这个引用被其他的方法或者是线程使用,那么这个引用,就是线程共享的,这个对象就会被分配进入堆区(线程共享),否则该对象直接被创建在虚拟机栈中(线程独有).
  3. 好处: 如果没有发生引用逃逸:
  • 对象会在栈上被分配,可以降低垃圾回收的开销,栈中的对象在没有引用的时候会自动被销毁.
  • 锁消除,如果返现某个对象只有一个线程在访问,就不存在多个线程同时使用一个对象的情况,那么在这个对象的操作可以不需要进行加锁.
  • 标量替换,会把对象分解成一个个基本的类型,并且内存分配不再是分配在堆上,而是分配在栈上.

1.2 程序计数器

  • 这个区域是数据区域中最小的区域.
  • 他的作用是:用来记录当前线程执行的行号.也就是当前执行指令(JVM字节码)的地址,这个地址就是元数据区中的一个地址.
  • 这块区域是各个线程都有自己单独的一块,也就是线程私有的.

1.3 方法区

  • 他的作用是:用来存储被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据.
  • 创建一个类之后产生的类对象就存储在这里.(类原信息)
  • 方法相关的信息也存储在这里,类中都有一些成员方法,每个方法都代表一系列"指令集合"(方法元信息).
  • 这里还存储着常量池,存储着字面量,比如字符串,final常量,基本数据类型的值.还存储着符号引用,比如类和结构的完全限定名,字段的名称和描述符,方法的名称和描述符.
  • 这个区域是各个线程共享的.
    在<<Java虚拟机规范>>中,把此区域称之为"方法区",而在HotSpot虚拟机的实现中,在JDK7时,此区域叫做永久代.JDK8中叫做元空间.

JDK1.8元空间的变化:

  1. 对于HotSpot来说.JDK8元空间的内存属于本地的内存,这样元空间的大小就不再受JVM最大内存相关配置参数影响,而是与本地内存大小相关.
  2. JDK8中将字符串常量池移动到了堆中.

1.4 Java虚拟机栈

Java虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,用户存储局部变量表,操作栈,动态链接,方法出口等.

  • 线程私有
  • Java虚拟机栈中的每个栈帧分为一下四部分:
    1. 局部变量表:存放编译器可知的8大数据类型,对象引用.
    2. 操作栈:每个方法会生成一个先进后出的操作栈,用于存储计算过程中的临时数据.
    3. 动态链接:指向运行时常量池的方法引用.
    4. 方法返回地址:方法执行完成之后返回的地址.
      在这里插入图片描述

1.5 本地方法栈

  • 和Java虚拟机栈类似,
  • 也是线程私有的
  • 他两的区别就在于,Java虚拟机栈是给JVM使用的,用来管理Java方法的调用,而本地方法栈是给本地方法使用的.本地方法指的是使用非Java语言编写的方法,如c/c++,通常通过Java本地接口调用.

1.6 不同形态的变量在数据区中的存储

基本原则:一个对象在哪个区域,取决于对应变量的形态.局部变量在栈上,成员变量在堆上,静态成员变量在元数据区.下面我们来举例说明:

class Test2{}
class Test{
	int a;
	Test2 t2 = new Test2();
	String s = "hello";
	static int b;
}
public static void main(String[] args){
	Test t = new Test();
}
  • main方法中的t变量在栈上,用来保存对象的首地址.(堆上的地址).
  • new Test()在堆上.是通过new创建出的一个对象.
  • new Test2()在堆上,和上一个是同理,也是通过new创建出的一个对象.
  • Test类中的成员变量都在堆上.
  • static修饰的是属于类的属性,就会出现在元数据区的类对象中.
    在这里插入图片描述

面试题: 堆和栈的区别

  • 堆分配的内存空间较大,栈分配的内存空间非常小.
  • 堆中存储的内容是创建的对象,栈中存储的是方法的栈帧以及局部变量.
  • 堆是所有线程共享的,而栈是一个线程私有的.
  • 溢出之后报出的错误不同,栈空间不足是StackOverFlowError,堆空间不足是OutOfMemoryError.

1.7 内存中布局中的异常问题

1.7.1 Java堆溢出

Java堆中用于存储对象的实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达的路径来避免来GC清除这些对象,那么在对象数量达到最大堆容量之后就会产生内存溢出异常.
我们上面提到,可以设置JVM参数-Xms:设置堆的最小值,-Xmx:设置堆的最大值.下面我们就来看一个Java堆OMM(OutOfMemory)的测试,测试之前首先设置idea的启动参数,如下所示:
在这里插入图片描述
在这里插入图片描述
JVM参数为:-Xmx20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError

/**
 * JVM 参数为:-Xmx20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError 
 * @author 38134
 *
 */
public class Test {
	static class OOMObject {
	
	}
	public static void main(String[] args) {
	List<OOMObject> list = 
	new ArrayList<>();
		while(true) {
		list.add(new OOMObject());
		}
	}
}

Java堆内存的OMM异常是实际应用中最常见的内存溢出情况,当出现Java堆内存溢出的时候,异常堆栈信息: “ava.lang.OutOfMemoryError"会进一步提示"Java heap space”.当出现"Java heap space"则很明确的告知我们,OMM发生在堆上.
那么如何解决堆溢出的问题?
此时要对Dump出来的文件进行分析,分析到底是出现了内存泄漏还是内存溢出.
内存泄漏: 泄漏的对象无法被GC.我们可以使用jconsole这样的监控工具来来监控堆内存的使用情况,观察是否存在持续增长且无法被GC的对象.
内存溢出: 内存对象确实还应该存活,此时要根据JVM堆参数与物理内存相比较检查是否应该把堆区的内存调大,或者检查对象的生命周期是否过长.

1.7.2 虚拟机栈和本地方法栈溢出

由于我们HotSpot虚拟机讲虚拟机与本地方法栈合二为一,因此对于HotSpot来说,栈容量只需要-Xss参数来设置.
关于虚拟机栈会产生如下两种异常:

  • 如果线程请求的站深度大于虚拟机所允许的最大深度,会抛出StackOverFlow异常.
  • 如果虚拟机在拓展栈的时候复申请到足够的内存空间,则会抛出OOM异常.

例如: 单线程情况之下观察StackOverFlow

/**
 * JVM参数为:-Xss128k  
 * @author 38134
 *
 */
public class Test {
	private int stackLength = 1;
	public void stackLeak() {
		stackLength++;
		stackLeak();
	}
	public static void main(String[] args) {
		Test test = new Test();
		try {
		test.stackLeak();
		} catch (Throwable e) {
		System.out.println("Stack Length: "+test.stackLength);
		throw e;
		}
	}
}

如果使用虚拟机默认的参数,栈深度在多数情况之下达到1000~2000完全没有问题,对于正常的方法调用完全够用.如果在单线程中出现了溢出问题,我们可以考虑增大栈内存或者是减少方法递归的深度来解决.
如果是因为多线程导致的内存溢出问题,在不能在减少线程数的情况之下,只能通过配置参数来增加栈内存的方式来换取更多的线程.
例如: 观察多线程的内存溢出异常:

/**
 * JVM参数为:-Xss2M  
 * @author 38134
 *
 */
public class Test {
 
 private void dontStop() {
		while(true) {
		
		}
	}
	public void stackLeakByThread() {
		while(true) {
			Thread thread = new Thread(new Runnable() {
			@Override
			public void run() {
				dontStop(); 
				}
			});
			thread.start();
		}
	}
	
	public static void main(String[] args) {
		Test test = new Test();
		test.stackLeakByThread();
	}
}

2. JVM类加载

一个Java进程要想跑起来,就要把Java先变成**.class文件.加载到内存中**,得到"类对象".这个所谓的跑起来,就是执行指令,要执行的CPU指令,都是通过字节码让JVM翻译出来的.

2.1 类加载过程(类的生命周期)

对于类加载的过程,总共分为一下几个步骤:

  1. 加载: 在硬盘上找到.class文件,读取文件内容.在寻找.Class文件并加载的时候,使用到双亲委派机制.
  2. 连接
    • 验证:.class里的内容,是否符合要求.class文件格式的内容在官方文档中有明确的规定,把读取到的内容,往这一套标准中套入,即可判断是否符合要求
    • 准备:给类对象分配内存空间,分配之后,把这个空间里的数据先全部填充为0.
    • 解析:把常量池中的符号引用转换为直接引用,所谓符号引用是一种描述符,是一种逻辑引用,比如java.util.List.size(),所谓直接引用就是目标方法在内存中的指针,是一种物理引用.也就是初始化常量的过程.
  3. 初始化:针对类对象的初始化,其中就包含对静态成员初始化,执行初始化,执行静态代码块.注意不是针对对象的初始化.
  4. 卸载: GC过程,我们在下面详细介绍.
    在这里插入图片描述

常见的内存分配方式.

  1. 指针碰撞:
    一般情况下,JVM的对象都放在堆内存中(发生引用逃逸除外),当类加载检查通过之后,Java虚拟机就开始为新生对象分配内存.如果Java堆中内存是绝对规整的,所有被使用过的内存都会被放到以便,空闲的内存放到另外一边,中间放着一个指针作为分界点的指示器,所分配的内存仅仅是把那个指针向空闲方向移动一段与对象相等大小的空间,这种分配方式称为指针碰撞.这种分配方式一般适用于复制算法进行GC之后的内存,空间比较规整.只需要移动指针即可完成分配.
    在这里插入图片描述
  2. 空闲列表
    如果Java堆中的内存并不是规整的,已经被使用的内存和空闲的内存相互交错在一起,那么久不可以进行指针碰撞,虚拟机必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表中找到一块大的内存空间分配给对象实例,并更新列表上的记录,这种分配方式叫做空闲列表.
    通常适用于标记-清除算法GC之后的内存.

2.2 双亲委派模型(类加载机制)

2.2.1 什么是双亲委派模型

在上述三部中,“加载"的过程中.会根据代码中"全限定类名”(包名+类名)找到对应的.class文件.在JVM加载.class文件,并找到.class文件的时候,就要用到双亲委派模型.
在JVM中包含这样一个特定的模块,叫做"类加载器",这个类负责完成后续的类加载工作.
如果⼀个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每⼀个层次的类加载器都是如此.

2.2.2 工作过程

在这里插入图片描述

  • 这其中有三个类加载器: AppClassLoader,ExtClassLoader,BootStrapClassLoader.这三个类加载器存在父子关系,如上图所示.
    [注意] 这里的父子关系不是通过类的继承方式来表示的,而是通过类加载器中的"parent"字段指向自己的父亲.
  • 三个类加载器的作用:
    • BootStrapClassLoader:加载标准库中的类.
    • ExtClassLoader:加载JVM扩展库中的类.(各JVM厂商,在上述的标准上,对JVM进行了扩展,这样的扩展在JVM标准之外,但是在安装了JVM就有.
    • AppClassLoader:加载第三方库的类和自己写的代码的类.
  • 他们几个类加载器的工作过程就类似于望父成龙的过程.
    • 工作从AppClassLoader开始,这个类加载器并不会立即搜索第三方库相关的目录.而是把任务交给自己的父亲来进行处理.也就是ExtClassLoader.
    • 工作到了ExtClassLoader,也不会立即对自己负责的扩展库进行搜索,也是把任务交给自己的父亲来处理.
    • 工作到了BootStrapClassLoader,BootStrapClassLoader也想交给自己的父亲进行处理.但是它的parent指向null,只能自己处理.在BootStrapClassLoader负责的标准库中的路径中搜索上述的类.
    • 如果找到了,就搜索完成了,类加载器负责打开文件,读取文件等后续操作.
    • 如果没有找到,任务还是要交给儿子来处理.
    • 工作回到ExtClassLoader,搜索扩展库的类,如果找到了,搜索完成,如果没找到,继续交给儿子处理.
    • 工作回到AppClassLoader,搜索第三方库中的类和自己创建的类.

举例说明:上司与下属处理问题
下属在发现一个问题的时候,不可以自己擅自处理,需要向上级上报,如果上报给上级之后,上级觉得这个问题非常重要,上级就会亲自处理,如果问题不是很重要,上级就会对下属说:"你自己看着办吧."于是下属就有权利处理这个问题了.

  • 优势/好处:
    • 确保一个类只被加载一次,保证类的全局一致性,由于类的加载只会不停地向上委托,比如java.lang.String这样的类只会被BootstrapClassLoader加载,而不是被子加载器加载,保证了类在JVM中的唯一性.同时也保证了用户自定义的一些类对核心类的篡改,防止了标准库中的核心API被破坏.
    • 提高类加载的效率,由于类会向上传递,父加载器加载过的类,子加载器就没有必要再加载了,保证了类加载的效率.
    • 支持模块化与隔离化,不同的类加载器可以加载来源不同的类.

2.2.3 双亲委派模型的打破

打破双亲委派机制历史上有三种方式,但是本质上只有第一种算是真正打破了双亲委派机制

  • 自定义类加载器并且重写LoadClass方法,Tomcat通过这种方式实现应用之间的隔离.
  • 线程上下文类加载器,利用上下文类加载器加载类,比如JDBC.
  • OSGI框架的类加载器(了解).
自定义类加载器

一个Tomcat程序中时可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证两个类都能加载并且他们应该是不同的类.如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同的限定名的MyServlet类就无法被加载.
在这里插入图片描述
Tomcat使用了自定义类加载器来实现应用之间类的隔离,每个应用都会有一个独立的类加载器加载对应的类.
在这里插入图片描述

问题:两个自定义类加载器加载相同的限定名的类,不会冲突吗?
不会,在同一个java虚拟机中,只有相同的类加载器+想通的类限定名才会被认为是同一个类.

线程上下文加载器
  • 启动类加载器(Bootstrap)加载DriveManager
  • 在初始化DriverManager是,通过SPI机制加载jar包中的MySQL驱动.
  • SPI中利用线程上下文加载器(Application)去加载器类并创建对象.
  • 这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制.
    在这里插入图片描述

问题: JDBC真的打破了双亲委派机制吗?
我们如果分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发驱动类加载器,类的加载依然遵循双亲委派机制,所以这里其实某种意义上来说并没有打破双亲委派机制.

3. JVM垃圾回收机制(GC)

3.1 什么是GC

这是Java提供的对于内存自动回收的机制.更本质地来说,GC其实回收的是"对象",回收的是"堆上的内存".回收对象的时候,一定是一次回收一个完整的对象,不能回收半个.
在这里插入图片描述

为什么GC只回收堆上的内存,不回收其他区域的?

  • 程序计数器:不需要额外回收,他是每个线程私有的,线程销毁之后,自然就回收了.
  • 栈:不需要额外回收,他是每个线程私有的,线程销毁之后,自然就回收了.
  • 元数据区:一般也不需要,都是加载类,很少卸载类.

3.2 GC的工作流程

GC的工作流程分为两步:

  1. 找到谁是垃圾
  2. 释放对应的内存

内存VS对象
在Java中,所有的对象都是要存在内存中的,因此我们将内存回收,也可以叫做死亡对象的回收.

3.2.1 判断谁是垃圾

判定一个对象是否是垃圾,判定的方式比较保守.判定某个对象是否存在引用指向它.使用对象,都是通过引用的方式来使用的,如果没有引用指向这个对象,意味着这个对象无法再代码中使用.于是就可以视为垃圾.

  • 引用计数算法
    [注意] 这种算法是PHP和python采用的算法,不是JVM采用的算法.
    给对象增加⼀个引用计数器,每当有⼀个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死".
    在这里插入图片描述
    但是这种方法存在很大的缺点,所以JVM才没有采用这样的方式.
    • 额外消耗的存储空间较大
    • 引用计数无法解决对象的循环引用问题
      什么是对象循环引用问题,我们来看下面这样一段代码.
    • 首先创建了两个对象,并使用a,b引用指向他们,对象的计数+1.
    • 之后两个对象中的t成员变量分别指向对方的引用,对象的计数+1.
    • 之后让a,b指向null,a,b指向的对象的计数-1,但是还没有减为零,所以两个对象没有被视为垃圾回收掉.
class Test{
	Test t;
}
public static void main(String[] args){
	Test a = new Test();
	Test b = new Test();
	a.t = b;
	b.t = a;
	a = null;
	b = null;
}

在这里插入图片描述

  • 可达性分析算法
    为了避免上述的两个问题,所以JVM引入了可达性分析算法.解决了空间和循环引用的问题.但是也付出了时间上的代价.
    这种算法的核心思想就是:遍历.
    JVM把对象之间的引用关系,理解为一个树形结构.JVM会不停遍历这样的结构,把所有可能遍历访问到的对象标记为"可达",剩下的是"不可达".
    例如下面这棵树:
    在这里插入图片描述
    此算法的核心思想为: 通过一系列称为"GC Roots"的对象作为起始点,从这些结点开始向下搜索,搜索走过的路径称为**“引用链”**,当一个对象到GC Roots没有任何引用链相连的时候,证明这个对象是不可用的.
    对象Object5和Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此它们会被判定为可回收对象.

在Java语言中,可以作为GCRoots的对象包含一下几种:

  1. 虚拟机栈中的引用对象.
public void method() {
    Object localObj = new Object();  // localObj就是GC Root
}
  1. 方法区中类静态属性引用的对象.
class MyClass {
    static Object staticObj = new Object();  // staticObj就是GC Root
}
  1. 方法区中常量引用的对象.
class MyClass {
    static final Object CONSTANT_OBJ = new Object();  // CONSTANT_OBJ就是GC Root
}
  1. 本地方法栈中JNI(Native方法,也就是c++实现的方法)引用的对象.

从上面可以看出"引用"的功能,除了最早我们使用它来查找对象,现在我们还可以使用"引用"来判断死亡对象了.所以在JDK1.2时,Java对引用的概念做了扩充,将引用分为强引用,软引用,弱引用和虚引用四种,这四种引用的强度依次递减.
5. 强引用:强引用指的是在程序代码之中普遍存在的,类似于"Object obj=new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
6. 软引用:软引用是用来描述⼀些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
7. 弱引用:弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下⼀次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。
8. 虚引用:虚引用也被称为幽灵引用或者幻影引用,它是最弱的⼀种引用关系。⼀个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得⼀个对象实例。为⼀个对象设置虚引用的唯⼀目的就是能在这个对象被收集器回收时收到⼀个系统通知,即跟踪对象被GC的时机。在JDK1.2之后,提供了PhantomReference类来实现虚引用

3.2.2 释放对应内存(垃圾回收)

把垃圾标记出来之后,就要对垃圾进行回收操作了.

  • 标记-清除算法
    这个算法分为标记和清除两个阶段.首先需要标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象.
    但是这种算法有一些不足:
    • 效率问题:标记和清除这两个过程的效率都不高.
    • 空间问题:标记清除之后会产生大量的不连续的内存碎片,空闲的内存被分成了一个碎片,不集中.空间碎片太多可能会导致以后子啊程序运行的时候需要分配较大对象的时候,无法找到足够连续的内存.
      在这里插入图片描述
  • 复制算法
    这种算法把内存空间分为了两个区域,一个是from区域,一个是to区域,使用内存的时候只使用from区域的内存.
    当from区域的内存需要进行垃圾回收时,会将此区域还存活着的对象复制到to区域上,然后再把from区域已经使用过的内存区域⼀次清理掉.
    在这里插入图片描述
    但是这种做法也有一个致命的缺点:复制的时间开销很大.
    现在的商用虚拟机(包括HotSpot都是采用这种收集算法来回收新生代)
  • 标记-整理算法
    标记过程仍与"标记-清除"过程⼀致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向⼀端移动,然后直接清理掉端边界以外的内存.;类似于顺序表删除中间元素的操作.
    在这里插入图片描述
    这种算法实质上和复制算法差别不大,也是会涉及到复制操作,也会产生较大的时间开销.
  • 分代算法(对象分配的规则)
    这种算法是JVM进行垃圾回收的主流算法.
    这种算法就是根据不同的区域和不同的垃圾回收策略进行垃圾回收..按照年龄,把对象分为新生代和老年带,其中新生代又分为伊甸区和生存区.这就和我们前面所提到的堆的数据区接轨了.
    在这里插入图片描述
    • 新创建的对象,如果这个对象比较大,会直接被放到老年代,防止这个较大的对象在新生代来回被拷贝而产生较大的开销,大部分的对象会被放到伊甸区,伊甸区的大部分对象,生命周期都比较短,当伊甸区满了的时候,第一轮GC就会触发,大多数对象就有可能成为垃圾,只有极少数可以活过第一轮来到生存区.
    • 生存区有两个,本质上是复制算法中的From区和To区,每经过一轮GC,生存区都会淘汰一批对象,剩下的进入到另一个生存区,到达另一个生存区是通过复制实现的.此外还有伊甸区新来的对象.每复制一次,年龄+1.就这样在两个生存区之间来回周转.新生代中的GC,不管是伊甸区还是生存区,都是通过复制算法来实现的.
    • 当新生代中的对象的年龄超过一定阈值的时候,这个对象就会被放入老年代.
    • 老年代对象也要经过GC,但是老年代的生命周期更长,就可以降低GC的频率.这里对老年代的GC,是通过"标记-整理"算法或者是"标记-清理"算法来完成的.关于这两种垃圾回收的机制,我们下面详细介绍

举例说明:校招找工作

  • 伊甸区:投放简历,大量简历被直接淘汰
  • 生存区:简历通过,要经过笔试,技术面,HR面等多轮筛选.
  • 老年代:进入公司,但是会定期考核.

3.3 常见的垃圾回收机制

3.3.1 FullGC

首先什么叫FullGC,FullGC就是对线程共享的区域,即堆区和元数据区进行大规模的GC,在非特殊情况下,我们一般不允许出现FullGC这种情况,因为这种情况会导致所有的用户线程暂停(Stop the World),而且暂停时间较长,对性能的影响非常大.
如何进行大规模垃圾回收? 一般包含以下的阶段,标记存活的对象,清理未被标记的对象,之后还可能会进行内存整理减少内存碎片,具体是否要进行内存碎片的整理,即使用标记-整理算法还是标记-清除算法,取决于使用的垃圾回收器..

什么叫stop the world?
在进行垃圾回收的时候,会设计对象的移动,为了保证对象引用更新的正确性,我们必须对所有的用户线程进行暂停,这样的停顿,虚拟机设计者把他形象的地描述为stop the world.在垃圾回收中Stop the world的次数主要取决于使用的垃圾回收器.

以下几种情况会触发FullGC:
由于FullGC清理的是线程共享的两个内存区域,我们首先就需要从这两个内存区域出发

  • 老年代空间不足,当新生代的对象转向老年代的时候,老年代的空间不足以为这个新转来的对象分配内存空间,就会进行大规模垃圾回收.
  • 元数据区空间不足,当系统中要加载的类,反射的类较多的时候,会导致元数据区空间不足.
  • 当大对象,大数组在直接进入老年代的时候,发现没有足够的连续的内存空间分配的时候,就会进行大规模的垃圾回收.

之后就是其他的情况:

  • 调用System.gc()方法的时候,但是是否要进行FullGC,是由JVM自己来决定的.

3.3.2 Major GC

Major GC通常与Full GC相关联,过程与出发条件和Full GC大相径庭,但是唯一与Full GC不同的是,Major GC只针对老年代进行垃圾回收,而FullGC正对元数据区(方法区)和整个堆区都会进行垃圾回收.由于老年代中的数据比较稳定,Major GC一般情况之下触发的频率比较低.

3.3.3 Minor GC

Minor GC(年轻代垃圾回收)是Java虚拟机(JVM)中针对年轻代(Young Generation)的垃圾回收过程.年轻代通常存放新创建的对象,其特点是对象生命周期短,回收频率高.MinorGC本质上采用的是复制算法来实现的.

Minor GC的过程主要分为一下过程:

  • 对象分配:新对象首先进入Eden区
  • Eden区满:当Eden区满时,触发Minor GC
  • 标记存活对象:标记Eden区和From Survivor区中存活的对象
  • 复制存活对象:将存活对象复制到To Survivor区,年龄加1
  • 清空Eden和From Survivor区:回收这些区域的内存
  • 交换Survivor区:From和To Survivor区的角色互换

面试题:请问了解MinorGC和FullGC么,这两种GC有什么不一样吗

  1. MinorGC又称为新生代GC:指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此MinorGC(采用复制算法)非常频繁,⼀般回收速度也比较快。
  2. FullGC一般发生在老年代或者是方法区。出现了MajorGC,经常会伴随至少⼀次的MinorGC(并非绝对,在ParallelScavenge收集器中就有直接进行FullGC的策略选择过程)。MajorGC的速度⼀般会比MinorGC慢10倍以上。

3.4 常见的垃圾收集器

如果我们说上面我们讲的收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现.
垃圾收集器的作用: 垃圾收集器是为了保证程序能够正常运行,持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到空间.
以下这些收集器是HotSpot虚拟机随着不同版本推出的重要的垃圾收集器:
在这里插入图片描述
上图中展示了7中作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们之间可以搭配使用.所处的区域,表示他是属于新生代的收集器还是老年代的收集器.在讲解具体的收集器之前我们先来明确三个概念:

  • 并行(parallel): 指的是多条垃圾收集线程的并行工作,用户线程任然处于等待状态.
  • 并发(Concurrent): 指的是**用户线程与垃圾收集线程同时执行,用户程序继续运行,而垃圾收集程序在另外一个CPU上.
  • 吞吐量: 就是CPU处于运行用户代码的时间与CPU总消耗时间的比值.

吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集时间)
下面我们来讲述两个最重要的垃圾回收器:CMS和G1

3.4.1 Concurrent Mark-Sweep收集器(CMS收集器,老年代收集器,并发GC)

  • 特点
    CMS收集器是一种以获取最短回收停顿时间为目标的收集器,目前很大一部分的Java应用集中在互联网站或者B/S系统的服务上,这类应用尤其重视服务的响应速度,==希望系统停顿时间最短,给用户带来较好的的体验.CMS收集器就非常符合这类应用的需求.

  • CMS收集器是基于标记-清除算法实现的,它的运行过程如下,分为4个步骤:

    1. 初始标记: 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,即在根对象的第一层孩子节点中标记所有可达的对象,速度很快,需要"Stop the world".
    2. 并发标记: 并发标记阶段就是进行GC Roots Tracing的过程(遍历对象引用链,标记所有可达的对象,未被标记的对象则被视为垃圾).这个过程用户线程不会被停止
    3. 重新标记: 重新标记阶段是为了修正并发标记期间因为用户程序继续运作而导致标记变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记稍长一些,这个过程依然需要Stop the world.
    4. 并发清除: 并发清除阶段会清除未被标记的垃圾对象.

由于整个过程中耗时最长的并发标记和并发清除过程收集器和线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的.

  • 优点
    并发收集,低停顿,旨在减少停顿时间,增大效率.
  • 缺点
    • CMS收集器对CPU资源非常敏感,面相并发设计的程序对CPU的资源都比较敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会应为占用了一部分线程而导致应用程序变慢,总吞吐量变低.
    • CMS收集器无法处理浮动垃圾.由于CMS并发清理阶段用户线程还在运行,伴随程序运行自然就会还有新的垃圾产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只好留待下一次GC再清理掉.这一部分垃圾就成为浮动垃圾.
    • 有可能触发时间更长的FullGC,也是由于在垃圾收集阶段用户线程还在运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满的时候再进行收集,需要预留一部分空间提供并发收集时的程序运作使用.==要是CMS运行期间预留的内存无法满足程序的需要,就会出现一次"Concurrent Mode Failure"失败,==这时候就会临时触发FullGC,停顿时间更长了.
    • CMS收集器会产生大量的空间碎片,CMS是基于"标记-清除"算法实现的收集器,这就意味着会有大量的内存碎片产生.当空间碎片过多的时候,就会给大对象的空间分配造成很大的麻烦,如果没有一块连续的空间分配大对象的话,就会触发FullGC.

在这里插入图片描述

3.4.2 G1收集器(唯一一款全区域的垃圾回收器)

G1垃圾回收器是用在heap Memery很大的情况下,把Heap划分为很多很多的region块,然后并行的对其进行垃圾回收.
G1垃圾回收器回收region(区域)的时候基本不会STW,而是基于most garbage优先回收的策略来进行region垃圾回收的.无论如何,G1收集器采用的算法都意味着一个region有可能属于Eden,survivor或者tenured内存区域.途中的E表示该region属于Eden内存的区域,s表示属于survivor内存区域,t表示属于Tenured内存区域.图中空白的表示未使用的内存空间,G1垃圾收集器还增加了一种新的内存区域,叫做Humongous区域,如图中的h块,这种内存区域主要用户存储大对象-即大小超过一个region大小的50%的对象.
在这里插入图片描述

年轻代垃圾回收

在G1垃圾回收器中,年轻代的垃圾回收过程使用复制算法.把Eden区和Survivor区的对象复制到新的Survivor区域.需要STW.
在这里插入图片描述

老年代垃圾收集

对于老年代上的垃圾收集,G1垃圾收集器也分为4个阶段,基本和CMS垃圾收集器一样,但是也略有不同:

  • 初始标记: 同CMS垃圾收集器的初始标记阶段.初始标记仅仅只是标记一下GC Roots能直接关联到的对象,即在根对象的第一层孩子节点中标记所有可达的对象.但是G1的垃圾收集器的初始标记阶段是跟新生代的垃圾回收是一起发生的.也就是说,在G1中不需要像在CMS那样,单独暂停应用程序的执行来运行初始标记阶段,而是在G1触发新生代垃圾回收的时候,一并带上老年代的初始标记也做了.所以老年代的该阶段也在STW阶段.但是需要注意的是,该阶段的STW和新生代的STW是同一个STW.
  • 并发标记: 在这个阶段,G1做的事情和CMS一样,但是G1同时还多做了一件事情,就是在并发标记阶段中,发现哪些Tenured region中对象的存活率很小,或者是基本没有存活,那么G1就会在这个阶段将其回收.而不用等到后面的筛选回收阶段.这就是Garbage First(G1)名字的由来.同时该阶段还会统计每个region的对象存活率,方便后面的筛选回收阶段的使用.
  • 最终标记: 在这个阶段G1做的事情和CMS一样,和CMS一样,需要STW,但是采用的算法不同,G1采用一种叫做SATB的算法,能够在remark阶段更快的标记可达对象.
  • 筛选回收: 在G1中,没有CMS中对应的Sweep阶段,但是他有一个Clean up/Copy阶段,在这个阶段中,G1会挑选出那些对象存活率低的region进行回收(也就是垃圾最多的区域),这个解读那也是与新生代垃圾回收一起发生的,所以也需要一次STW,如下图所示:
    在这里插入图片描述
    如果你的应用追求低停顿的话,G1可以作为选择,如果你的应用最求吞吐量,G1并不会带来明显的好处.

CMS和G1的不同:

  1. G1在初始标记的时候,G1会和新生代的垃圾回收同时进行,CMS只是正对老年代的垃圾回收,并不涉及这个概念
  2. G1在并发标记的时候,G1会把那些内存利用率不高的区域进行垃圾回收,同时标记每个区域的对象存活率
  3. G1在最终标记的时候,与CMS采用了不同的算法,G1采用了SATB的算法.
  4. G1在筛选回收的时候,会根据之前对象存活的标记,选择一个存活率最低的进行回收,老年代和新生代会同时进行垃圾回收.

接下来的几个垃圾回收器不常考,但是还是有必要了解一下的.

3.4.3 Serial收集器(新生代收集器,串行GC)

Serial收集器是最基本的,发展历史最长的收集器,曾经是新生代收集器的唯一选择.

  • 特性: 这个收集器是一个单线程的收集器,但是他的"单线程"的意义并不仅仅说明它只会使用一个CPU或者一条收集线程完成垃圾回收,更重要的是它在进行垃圾回收的时候,**必须暂停其他所有的工作线程,直到它收集结束(Stop the world,STW).
  • 应用场景: Serial收集器是虚拟机运行在Client模式下的默认新生代收集器.
  • 优势: 简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集,自然可以获得最高的单线程收集率,实际上直到现在,它依然是虚拟机运行在Client模式之下的默认新生代收集器.
    在这里插入图片描述

3.4.4 Parallel Scavenge收集器(新生代收集器,并行GC)

  • 特性: Parallel Scavenge收集器是一个新生代收集器,他是使用复制算法的收集器,又是并行的多线程收集器.这个垃圾回收器主要追求的是高吞吐量.
    Parallel Scavenge收集器使用两个参数控制吞吐量:
XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
XX:GCRatio 直接设置吞吐量的大小

直观上,只要最大的垃圾收集器停顿的时间越小,吞吐量是越大的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的.比如原来10秒收集一次,每次停顿100毫秒,现在编程了5秒收集一次,每次停顿70毫秒,停顿时间下降的同时,吞吐量也下降了.

  • 应用场景: 这个垃圾回收器主要追求的是高吞吐量,吞吐量可以高效利用CPU时间,尽快完成任务,主要适合在后台运算而不需要太多交互的任务.

3.4.5 Serial Old收集器(老年代收集器,串行GC)

  • 特性: Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法.
  • 应用场景: 在Client模式之下,SerialOld收集器的主要意义也是在于给Client模式下的虚拟机使用.而在Server模式之下,那么它主要还有两大用途: 一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge搭配使用,一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用.
    在这里插入图片描述

3.4.6 Parallel Old 收集器(老年代收集器,并行GC)

  • 特性: ParallelOld是Parallel Scavenge收集器的老年版本,使用多线程和标记-整理算法.
  • 应用场景: 这个收集器是在JDK1.6中才开始提供的,在此之前,新⽣代的ParallelScavenge收集器⼀直处于⽐较尴尬的状态。原因是,如果新⽣代选择了ParallelScavenge收集器,⽼年代除了SerialOld收集器外别⽆选择(ParallelScavenge收集器⽆法与CMS收集器配合⼯作)。由于⽼年代SerialOld收集器在服务端应⽤性能上的“拖累”,使⽤了ParallelScavenge收集器也未必能在整体应⽤上获得吞吐量最⼤化的效果,由于单线程的⽼年代收集中⽆法充分利⽤服务器多CPU的处理能⼒,在⽼年代很⼤⽽且硬件⽐较⾼级的环境中,这种组合的吞吐量甚⾄还不⼀定有ParNew加CMS的组合“给⼒”。直到ParallelOld收集器出现后,“吞吐量优先”收集器终于有了⽐较名副其实的应⽤组合。

3.4.3 一个对象的一生

⼀个对象的一生:我是⼀个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有⼀天Eden区中的⼈实在是太多了,我就被迫去了Survivor的“From”区(S0区),自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区(S1区),居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了很多年(每次GC加⼀岁)然后被回收了.
在这里插入图片描述

4. JMM

JVM中定义了一种Java内存模型(Java Memory Modle,JMM)来屏蔽掉各种硬件和操作系统之间访问差异,一实现让Java程序在各种平台之下都能够达到一直的内存访问效果.真正做到一次编译,到处运行.

4.1 主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节.此处的变量包括实例字段,静态字段和构成数组对象的元素.但是不包括局部变量和方法参数,因为前后两者是线程私有的,不会被线程共享.
Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成.
在这里插入图片描述

4.2 内存之间的交互操作

关于主内存与工作内存之间的具体交互协议,即⼀个变量如何从主内存中拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了如下8种操作来完成.

  • Lock(锁定): 作用于主内存的变量,它把⼀个变量标识为⼀条线程独占的状态.
  • unlock(解锁):作用于主内存的变量,它把⼀个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存的变量,它把⼀个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用.
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中.
  • use(使用):作用于工作内存的变量,它把工作内存中⼀个变量的值传递给执行引擎.
  • assign(赋值):作用于工作内存的变量,它把⼀个从执行引擎接收到的值赋给工作内存的变量.
  • store(存储):作用于工作内存的变量,它把工作内存中⼀个变量的值传送到主内存中,以便后续的write操作使用
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中.

Java内存模型的三大特性

  • 原子性(因为在第一部就Lock了): 由Java内存模型来直接保证原子性变量操作包括read、load、assign、use、store和write.大致可以认为,基本数据类型的访问读写是具备原子性的,如果需要大范围的原子性则需要Synchronized关键字约束.
  • 可见性: 可见性是指当一个线程修改了共享变量的值,其他的线程能够立即得知这个修改.volatile,Synchronized,final这三个关键字可以实现可见性.
  • 有序性: 它描述的是多线程环境之下,操作的执行顺序是否与程序代码一致.由于现代处理器和编译器会对指令进行优化(如指令重排序),在多线程环境下,操作的实际执行顺序可能与代码中的顺序不一致,从而导致程序行为出现异常.
    其中JMM通过happens-before规则来定义操作之间的顺序关系,确保多线程程序的有序性.
    下面来具体介绍一下happens-before原则.(现行发生原则):
    • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
    • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
    • volatile变量规则:对⼀个变量的写操作先行发生于后面对这个变量的读操作
    • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
    • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个⼀个动作
    • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
    • 对象终结规则:⼀个对象的初始化完成先行发生于他的finalize()方法的开始

4.3 volatile型变量的特殊规则

见线程安全(下)章节

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐