JVM上篇
JVM与Java体系结构
JVM的整体结构
字节码文件-----> JVM
这个过程是通过classloader 即 类装载器子系统。
classloader的作用是将字节码文件加载到内存中,生成一个Class对象,这个过程中涉及到加载、链接、初始化。其中链接有三步
运行时数据区
- 方法区、堆 是多个线程共享
- 虚拟机栈(Java栈)、本地方法栈和程序计数器是每个线程独有一份
执行引擎
- 解释器
- JIT即时编译器
- 垃圾回收器
我们说把字节码文件加载到内存中,下一步就是解释运行,解释运行这一步就是用到解释器,如果只用解释器,性能就差一些,对于一些热点代码,我们希望提前编译出来,就要用到JIT即时编译器。这里的编译器和javac.exe编译java源文件到字节码文件时不同的,javac.exe编译java源文件叫编译器的前端,JIT即时编译器叫编译器的后端。
操作系统只能够识别机器指令,但是字节码指令虽然是二进制的,但是它不等同于机器指令,执行引擎就充当了把高级语言Java语言翻译成机器语言的翻译者。
操作系统并不识别字节码指令
Java代码执行流程
高级语言翻译成机器指令的过程其实就是由执行引擎来执行的
市面上主流的虚拟机都采用了解释执行和即时编译并存的方式
解释器是保证响应的时间的,逐行地对字节码指令进行解释执行
JIT是对热点代码的字节码指令再编译成机器指令,这是二次编译,第一次编译是把源文件编译成字节码文件,第二次是把字节码文件中的字节码指令编译成机器指令,这部分机器指令是反复执行的热点代码,还缓存起来了,存储在方法区中,保证程序执行的性能,所以市面上主流的虚拟机都采用二者并存的方式。
JVM的架构模型
Java编译器输入的指令流是基于栈的指令架构
另外一种指令集架构是基于寄存器的指令集架构
栈是内存层面的,不需要和硬件打交道,所以有更好的可移植性
基于寄存器的方式,性能优秀,指令需要由CPU来执行,在高速缓冲区中进行执行,和硬件的耦合度高,
栈式架构的指令集少,但是完成一个操作的指令多,寄存器架构完成同样操作的指令少。
JVM的生命周期
自定义类是由系统类加载器加载的,自定义类会默认继承Object类,Object类就是由引导类加载器加载的。
Java虚拟机的启动是通过引导类加载器创建一个初始类来完成的。这个初始类不是Object类
执行一个简单的程序,实际上会加载非常多的类,Object类是属于引导类加载器加载的类之一
父类的加载是早于子类的
一个JVM对应于一个运行时数据区,也就是运行时环境,对应于Runtime类,Runtime类是单例的,是饿汉式
主流的JVM既提供解释器,也提供JIT即时编译器,解释器逐行解释执行字节码文件,编译器是将字节码编译成机器指令,因为字节码虽然是二进制文件,但是并不是机器指令,这是一次再编译,目的就是将热点代码编译成机器指令并进行缓存,提高执行效率。
第一次编译是将Java源文件编译成字节码文件
第二次编译是将字节码的热点代码编译成机器指令。
类的加载器就有几种,有引导类加载器,系统类加载器,一个自定义类就是通过系统类加载器进行加载,作为Class类的对象加载到方法区中。
JVM启动是通过引导类加载器,即bootstrap class loader 创建一个初始类。Object类就是引导类加载器加载的。
执行引擎的作用就是将高级语言翻译成机器指令
为什么不把所有字节码都编译成机器指令,都进行缓存?
执行引擎中的JIT即时编译器,将字节码再编译成机器指令,这也是需要花时间的。如果编译每一行,会导致暂停时间过长。暂停的时间就是用来编译的。
解释器是保证响应时间的,响应很快,上来就执行,因为不需要编译!
解释器和JIT即时编译器要搭配使用,这也是Java说是半编译半解释运行的原因
方法区的概念只有HOTSPOT才有
通过计数器找到最具编译价值代码,将字节码指令翻译成机器指令,在本地缓存起来,下次需要的时候直接执行
解释器主要负责的是响应时间,编译器主要解决的是执行的性能,因为将字节码指令编译成机器指令是需要花时间的,编译好并缓存好之后才解决性能。解释器就负责响应,所以两者需要结合起来。
类加载子系统
概述
类加载器子系统详细可见下面这张图,类加载器子系统。
类加载器又有多种,比如引导类加载器、系统类加载器
类的加载
- loading 加载
- 引导类加载器
- 扩展类加载器
- 系统类加载器
- 也可以自定义类加载器
- linking 链接
- 验证
- 准备
- 解析
- initialization 初始化 (涉及到静态变量的显示初始化)
- loading 加载
程序计数器是每个线程一份
我们平时说的栈是指虚拟机栈
堆区是被多个线程共享的
方法区主要用来存放类的信息、常量等等,方法区只有hotspot才有
执行引擎分为解释器、JIT编译器、垃圾回收器
如果自己想手写一个虚拟机的话,要考虑
- 类加载器子系统
- 执行引擎
类加载器子系统作用
class字节码文件在开头有特定文件标识,这个验证是在链接阶段第一个阶段验证阶段来验证
字节码文件是物理磁盘上的文件,类的加载器主要是把这个物理磁盘上的字节码文件加载到内存当中,生成Class的实例,生成到方法区中
加载分为加载、链接、初始化三个部分,恰好第一个部分也叫加载
方法区在jdk7即以前叫永久代,之后叫元空间,都是方法区的具体实现
生成Class的对象实例是在加载这个环节出现的。
链接分为三个子阶段:
验证
字节码文件起始都是叫
CA FE BA BE
,这称为魔术,所有能被JVM识别的字节码文件,有效起始都是这个,通过这个来进行校验字节码文件也是二进制文件,但是字节码指令不是机器指令!虽然字节码文件是二进制的。所以jvm结构中有执行引擎,执行引擎就有将高级语言转换成机器指令的作用,所以在执行引擎中,JIT即时编译器要对字节码指令编译成机器指令,这是二次编译
字节码文件是二进制文件,很容易伪造。所以需要进行验证,看所验证的字节码文件是否是符合当前虚拟机要求,保证被加载类的正确性。
准备
静态变量(或者叫类变量),a被显式赋值为1,但是在链接的准备阶段,是赋值为0,在初始化阶段才会赋值为1。链接的准备阶段,是为静态属性赋默认初始值,初始化阶段才是显式赋值
这里不包含用final修饰的static变量,因为final在编译的时候就会分配了,准备阶段会显式初始化
这里不会为实例变量分配初始化,因为这个时候还没有创建对象,还是一个类的加载过程。类变量(可以理解为类的信息,类变量也是静态变量,属于静态结构,是随着类的加载而加载的),类变量会分配在方法区中,而实例变量是随着对象一起分配到Java堆中。
虚拟机栈里存的是局部变量,那么成员变量就分为静态变量和非静态变量,静态变量就是随着类的加载而加载,是属于类的结构,会分配在方法区中,因为类在被classloader,也就是类加载器子系统加载之后,类的信息、结构会加载到内存的方法区中,而非静态变量不会,非静态变量会随着实例对象的创建,和对象实例一起,分配到Java堆空间中
属性也就是成员变量是有默认初始值的,而局部变量没有,必须显式赋初始值,属性中的静态变量在链接的准备阶段就会默认赋初始值了,而非静态变量会随着对象实例的创建而创建,分配到堆空间中。虽然都是成员变量,但是他们的生命周期是不同的。
解析
将符号引用转换为直接引用
初始化
执行类构造器方法<clinit>()的过程
任何一个类,声明以后,内部至少存在一个类的构造器,这里说的构造器就是我们平常说的构造器了,可以显式声明,也可以是系统默认提供的, 这个在字节码文件里对应于<init>(严格来说字节码文件是二进制文件,字节码文件翻译之后会有这个<init>),这个就是指的我们说的构造器
而<clinit>这个指的是类里面的所有静态变量(类变量)的显式赋值动作以及静态代码块里的内容。<clinit> 是静态变量赋值和静态代码块的语句
子类的<clinit>执行一定会晚于父类的<clinit>的执行
虚拟机执行类的加载的时候,只会调用一次<clinit>方法,类加载之后会在内存中缓存起来,也就是说,一个类只会被加载一次
类加载器的分类
JVM支持两种类型的类加载器,分别为引导类加载器,和自定义类加载器,
所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。所以系统类加载器也属于自定义类加载器
bootstrap classloader即引导类加载器不是Java语言编写的,在代码里也不能通过getParent()的方式获取到
对于用户自定义类来说,默认使用系统类加载器进行加载
Object类则是用引导类加载器进行加载
String类是用引导类加载器进行加载
系统的核心类库都是使用引导类加载器进行加载的。
引导类加载器在代码里没办法获取到。
引导类加载器是使用C和C++来编写的,嵌套在JVM内部(可以理解为就是JVM中的一部分,就是用来加载Java的核心类库), 自定义类加载器是使用Java语言编写的。
引导类加载器并不继承自ClassLoader,没有父加载器
引导类加载器加载扩展类和应用程序类加载器(系统类加载器),并指定为他们的父类加载器
代码里获取到类的加载器之后,发现这些类的加载器也是对象,这些对象对应的类也是需要加载的,就是通过引导类加载器进行加载的。
也就是说比如自定义类是通过系统类加载器加载的,而系统类加载器是通过引导类加载器加载的。
出于安全考虑, 引导类加载器只加载java、javax、sun等开头的类
引导类加载核心类库!
引导类加载器也叫启动类加载器,Bootstrap ClassLoader
凡是和底层操作系统编程相关的,还是考虑C和C++,但是现在随着硬件的发展Java的执行效率和C已经不相上下,在最初的时候,Java的启动类加载器还是用C和C++来编写的
扩展类加载器是虚拟机自带的加载器,是Java语言编写的,继承于ClassLoader类,父类加载器为启动类加载器,也就是引导类加载器。
注意在这里父类和父类加载器是不同的
扩展类加载器就是加载核心类库之外的扩展的那些包
应用程序类加载器也叫系统类加载器,AppClassLoader
也是派生于ClassLoader类
父类加载器为扩展类加载器,扩展类加载器的父类加载器为启动类加载器,父类加载器和父类不同。
该类加载器是程序中默认的类加载器
在必要时,用户还可以自定义类加载器
为什么要自定义类加载器
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄漏
隔离加载类就是说在同一个项目中,引入不同的框架,或者在框架中用到了中间件,中间件和应用是隔离的,那么就需要把类加载到不同环境中,比如应用的jar包,让jar包不冲突
类加载器分为两类,一类是引导类加载器,另一类是继承于ClassLoader的类加载器即自定义类加载器
抽象类里面可以有不抽象的方法
系统类加载器的父类加载器是扩展类加载器
扩展类加载器的父类加载器是引导类加载器
大的Class的实例clazz可以获取当前类的ClassLoader
clazz.getClassLoader();
双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时,才会将它的class文件加载到内存生成class对象。
加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交给父类处理。它是一种任务委派模式
静态结构是随着类的加载而加载,随着类的结构被加载到内存中的方法区中,类的加载就是类作为Class的实例被加载到内存中的方法区中,作为类的信息、结构的调用的接口
属性也就是成员变量分为静态和非静态的,非静态的变量是随着对象的加载而加载到内存的堆空间中,而静态变量是静态结构,随着类的加载过程,被加载到方法区中,类作为Class的对象,是类的信息和各种数据的访问入口。
静态代码块的代码也会随着类的加载而执行
静态代码块是在类加载的第三个阶段,初始化阶段调用的!<clinit>调用的是静态变量显式赋值和静态代码块内的语句。前两个阶段是加载和链接,其中链接又分为三个阶段
静态变量显式赋值和静态代码块内的语句会被放在<clinit>构造器中进行执行。而<init>构造器是指的我们平常说的构造器,就是类的构造器。
要分清楚<clinit>和<init>这两种构造器
类的加载过程分为加载、链接、初始化,其中链接分为验证、准备、解析
其中加载过程具体有三点:
- 通过全限定名获取此类的二进制字节流
- 将二进制字节流代表的静态存储结构转换为方法区中的运行时结构
- 将此类作为Class的对象,加载到内存中的方法区中,作为该Class对象各种信息和方法调用的访问入口
双亲委派机制工作原理
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终会到达顶层的启动类加载器(引导类加载器)。
这个过程和类的加载有点像,类的加载过程中,子类的加载之前一定要有父类的加载,最终会到Object类需要加载
如果父类加载器可以完成类加载任务,就成功返回,如果父类加载器不可以完成任务,子类加载器才会自己去尝试加载,这就是双亲委派模式。
引导类加载器加载完之后,子类加载器就不会去加载了。
比如一个自定义类加载过程中的双亲委派机制。
类加载过程中涉及到类装载子系统,就是类加载器,类加载器有工作机制,就是双亲委派机制,比如一个自定义类,那么就对应于系统类加载器收到了类加载请求,然后向上委托,最终到达顶层的引导类加载器,引导类加载器是加载Java的核心类库,它不管自定义类的加载,就向下委托(严格来说不叫向下委托,就是父类加载器加载失败,交由子类加载器自己处理),扩展类加载器也不管,最后是才是系统类加载器负责加载我们的自定义类
类加载过程中,只有一个类加载器加载就行了。父类加载器不加载的话,那么就给子类加载器自己加载!
去执行类里面的代码,或者说运行过程,会进行类的加载,有些代码运行不了,或者运行失败,可以试试从类的加载这个过程去考虑。
比如自定义String类里写main方法,为什么运行失败,因为类加载时,由于双亲委派机制,最终由引导类加载器加载String类,那么加载的是核心类库的String类,而不是我们自定义的String类
对象.getClass() == 类.class
为true,等号两边都是Class的对象实例Class的对象实例什么意思?
就是这个类本身就是对象,是Class的对象
双亲委派机制的优势
- 避免类的重复加载,一旦有一个类的加载器去加载了,另外的加载器就不会去加载了。
- 保护程序安全,防止程序被恶意篡改
自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的java.lang包下的String类,报错信息说没有main方法,就是因为jdk自带的String类这个核心类没有main方法,这样可以保证对Java核心源代码的保护,这就是沙箱安全机制
在JVM中表示两个class对象是否相同的两个必要条件:
类的完整类名必须一致,包括包名
加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
这里说的类加载器是指实例对象,类加载器本身又是对象,它对应的类是属于Java类库的,比如ClassLoader抽象类,所以类加载器对应的类是由引导类加载器加载的
换句话说,在JVM中,即使类名相同,但是加载他们的类加载器ClassLoader不同,那么这两个类对象(Class的对象)也是不相等的。
系统类加载器加载自定义类,这个类加载器的信息会在方法区中进行记录,就是这个类加载器的一个引用会作为类型信息的一部分保存在方法区中
运行时数据区概述及线程
概述
类加载过程分为加载、链接、初始化三个过程
加载完以后,内存中的方法区中就保存了运行时类本身,加载到内存中的类叫做运行时类!
接下来就要用执行引擎去做执行,执行引擎分为解释器(保证响应)、JIT即时编译器(保证执行效率, 热点代码二次编译及缓存)、垃圾回收器三部分。执行引擎执行的过程中,都要用到运行时数据区
执行引擎的JIT即时编译器可以把字节码指令翻译成机器指令
内存是非常重要的系统资源,可以理解为运行内存,时硬盘和CPU的中间仓库和桥梁,承载着系统和应用程序的实时运行
内存一定要和实时运行挂钩
JVM内存布局规定了Java在运行过程中的内存申请、分配、管理的策略
运行时数据区就是JVM内存布局,就理解为内存
CPU读的数据都来自于内存,或者说CPU直接交互的对象就是内存!
红色的是所有线程共享的,即方法区(jdk8及以后也可以叫元空间)和堆空间
灰色的是每个线程私有的,即本地方法栈、虚拟机栈和程序计数器
运行时数据区中,其中有一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁,与虚拟机的生命周期是一样的,另外一些则是与线程一一对应的,与线程的生命周期是一致的。
如果有5个线程,就是有5组程序计数器、本地方法栈、虚拟机栈,但是只有一个方法区和堆
堆空间和方法区是共用的
类只会加载一次,类加载过程中的初始化<clinit>需要保证同步
方法区主要放类的信息
从频率上讲,95%的垃圾都集中在堆区,5%集中在方法区
JIT编译以后的代码缓存有的认为是方法区的一部分,有的认为不是,不用细抠,但是要明确JIT编译的代码缓存不在堆空间
一个JVM实例就对应一个Runtime实例,Runtime实例对象就对应于运行时数据区,并且只有一个Runtime实例,是单例的
在Hotspot jvm里,每个线程都与操作系统的本地线程直接映射,当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建,Java线程执行终止后,本地线程也会回收
如果执行线程的过程中,出现了异常没有处理,会导致Java线程终止,此时本地线程决定JVM要不要终止,取决于当前线程是不是最后一个非守护线程
一个简单的程序,后台都有许多的线程,这些线程不包括main线程和main线程里创建的线程
这些后台线程主要有:
虚拟机线程
周期任务线程
GC线程,对垃圾收集行为提供了支持
编译线程
信号调度线程
程序计数器
也称为PC寄存器,就是CPU的寄存器的物理结构的一个抽象模拟,因为Java虚拟机是软件层面的概念,运行时数据区可以理解为内存
也叫程序的钩子,这个钩子可以理解为钩程序的,就是上一行执行完只会,下一行该执行谁了,由PC寄存器来做一个记录
PC寄存器用来存储指向下一条指令的地址,由执行引擎根据PC寄存器的下一条指令地址读取下一条指令
PC寄存器是一块很小的内存空间
每个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致。记录执行到哪里了
任何一个线程都只有一个方法在执行,也就是所谓的当前方法,栈最上面的方法。PC寄存器存储指向当前方法的指令的地址!(这里的当前方法是说栈顶的方法,就是下一条指令)
栈是只有入栈和出栈,不考虑垃圾回收,PC寄存器也没有垃圾回收机制,堆空间和方法区有垃圾回收机制
PC寄存器不会发生OOM(OutOfMemory)溢出
栈结构没有垃圾回收,但是有可能会溢出,堆和方法区也可能会溢出
总结:
堆、方法区:线程共有,有垃圾回收,可能溢出
虚拟机栈:线程私有,没有垃圾回收,可能溢出
PC: 线程私有,没有垃圾回收,不会溢出
指令地址右边的结构叫操作指令
执行引擎取出指令后,会操作局部变量表、操作数栈,会把字节码指令翻译成机器指令,机器指令就可以被CPU运算,这是二次编译,解释器就是对字节码逐行解释执行,所以Java是半编译半解释语言
PC寄存器的一个面试问题:
使用PC寄存器存储字节码指令地址有什么用?
为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停地切换各个线程,这个时候切换回来以后,就需要知道从哪里继续执行,因为PC寄存器存的就是字节码指令的地址!
JVM字节码解释器就需要通过改变PC寄存器的值,来明确下一条应该执行什么样的字节码指令
字节码解释器是逐行执行字节码指令的,执行引擎通过PC寄存器拿到字节码指令地址,进而拿到字节码指令,然后执行,整个过程就是对字节码指令的逐行解释执行,这里的逐行执行指的不是Java代码!
执行引擎里有字节码解释器、编译器、垃圾回收器
对字节码文件进行反编译操作就能看到字节码指令地址和字节码指令
PC寄存器为什么是线程私有的?
假如三个线程并行执行,实际上是并发执行,因为他们抢占同一个CPU资源
要注意PC寄存器里面记录的是下一条要执行的字节码指令的指令地址,比如线程1该执行第5行字节码指令,现在切换到另一个线程了,当然不可以接着5执行,PC寄存器必须每个线程一份,分别记录自己的该执行的下一条字节码指令的地址!
如果对应于一个CPU核,有三个线程,那么任何一个确定的时刻,只有一个线程执行。看似并行执行,实际是并发执行
CPU快速切换执行多个线程,就是并发
虚拟机栈
概述
Java是跨平台的语言,有三个特性
健壮性
面向对象性
跨平台性
其中跨平台性就是因为JVM,展开来说就是Java源文件先经过编译,编译成字节码文件,字节码文件会被加载到JVM内存里,变成运行时数据。而JVM在不同系统平台上有不同的实现,同一份代码,同一份字节码文件可以在不同系统平台的JVM上加载、运行。
一次编译、多次运行
JVM的指令是根据栈来设计的,不同平台CPU的架构不同,所以不能设计为基于寄存器的,不能设计为基于硬件的,而JVM是软件层面,所以设计为基于栈的指令集
指令集小,但是实现同样的功能,需要更多的指令
相对寄存器来讲性能更差,因为寄存器是硬件CPU层面的
JVM中的内存中,栈和堆是非常重要的两个结构!
栈是运行时的单位,而堆是存储的单位
栈解决程序的运行问题,局部数据变量是放在栈中的,如果是引用类型变量,放的是对象应用,指向堆空间中的地址
栈的存储单位是栈帧,栈帧里又会细分局部变量表、操作数栈等
堆空间的大小是可以设置的,方法区现在可以设置本地内存了,是物理的内存了,不考虑方法区的话,堆空间是内存中最大的区域
要理解到栈里不只有局部变量表,还有字节码指令,而不是Java代码!都进入到运行时数据区了,怎么可能是Java代码,Java源文件经编译后,运行才会通过类加载子系统加载进运行时数据区,才会从静态的结构变成运行时数据结构。
要牢记字节码----对应于JVM。
虚拟机栈和线程的生命周期是一样的,一个线程对应一个Java虚拟机栈。虚拟机栈是线程私有的
栈里面保存的是一个个的栈帧,一个栈帧就对应着一次方法调用!栈顶的方法称为当前方法!
虚拟机栈主管Java程序的运行,保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回
每个方法执行,伴随着进栈
执行结束后,出栈
对于栈来说,不存在垃圾回收问题
栈溢出异常,递归如果说不朝着退出递归的条件逼近,就会报栈溢出异常。递归一定要朝着结束递归的方向进行
-Xss
可以这是栈的大小,EditConfiguration里VM options设置
栈的存储单位
栈中的数据都是以栈帧的格式存在,以栈帧为基本单位
这个线程正在执行的每个方法都各自对应一个栈帧
方法和栈帧是一一对应的关系
栈帧是一个内存区块,是一个数据集,维系着方法执行过程种的各种数据信息
栈的操作只有压栈和出栈
在一个活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。
这个栈帧被称为当前栈帧
要注意栈顶栈帧对应着一个当前正在执行的方法。而pc寄存器存储的是下一次要执行的字节指令的地址
如果在当前方法中调用了其他方法,那么对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
这是栈,先进后出,后进先出,后创建,后进栈,先执行
程序计数器来指定执行引擎要执行的下一个指令
main()方法也对应一个栈帧
不同的栈的数据是不可以共享的,即不可能在一个栈帧中引用另一个栈的栈帧
方法的结束方式分为两种
- 正常结束,以return为代表
- 方法执行中出现未被捕获的异常,以抛出异常的形式结束
栈帧的内部结构
一个栈帧对应于一个方法
一个栈帧的入栈对应于一个新的方法的调用
一个栈帧的出栈对应于一个方法执行的结束,以正常方式结束,或者返回异常返回给前一个方法
栈帧是需要有大小的,取决于内部结构的大小
栈帧内部:
- 局部变量表
- 操作数栈(或表达式栈)
- 动态链接(或指向运行时常量池的方法引用)
- 方法返回地址(或方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表
局部变量表也叫局部变量数组或本地变量表
局部变量表的数据类型主要包括各类基本数据类型、对象引用、以及返回地址类型
局部变量表是数字数组,8种基本数据类型都用数值来表示,对象引用(引用变量或者说引用地址)、返回值类型都可以用数值类型来表示
局部变量表的存储单位是slot,变量槽
局部变量表由于是在栈的栈帧里,栈是线程私有,那么就涉及不到共享的问题,就自然没有线程安全问题了。
局部变量表的大小在编译期间就确定下来,一旦确定下来,在运行期间就不会更改。
一个栈帧对应于一个方法,每个方法里有局部变量,就对应于每个栈帧的局部变量表。在编译期间确定局部变量表大小
主要影响栈帧的大小的就是栈帧里局部变量表的大小
javap指令解析字节码文件,能够看到字节码指令,或通过插件
程序计数器存的是下一条要执行的字节码指令的指令地址,是数值!
局部变量表每个slot的StartPC 指的是声明了这个局部变量之后,下一行开始执行的Java代码对应的字节码指令地址
JVM会为局部变量表中每一个slot都分配一个访问索引
构造器在字节码层面会生成<init>,字节码文件解析之后,能够看到<init>
如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处。其余的参数按照参数表继续排列
每一个slot都会分配索引,那this就分配在索引为0的slot处
引用类型占据一个slot,double类型变量占据两个slot
栈帧中的局部变量表中的槽位是可以重用的。如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位
成员变量(类里面,方法外部定义的变量)
- 类变量(静态变量,随着类的加载而创建,分配到方法区中)
- linking的prepare阶段:给类变量默认赋值(这个赋值,不包括给实例变量默认赋值,因为加载、链接、初始化三个阶段是类的加载过程,现在是类的加载,加载的是类的结构,静态变量是随着类的加载而加载,但是实例变量不是,实例变量是随着对象创建而分配到堆中)
- initial阶段:给类变量显式赋值及静态代码块赋值<clinit>
- 实例变量(随着对象的创建而分配到堆空间中)
- 随着对象的创建,会在对空间中分类实例变量空间,并进行默认赋值
局部变量(方法内部的变量)
- 在使用前,必须要进行显式赋值,否则编译不通过
- 类变量(静态变量,随着类的加载而创建,分配到方法区中)
局部变量表中的变量也是重要的垃圾回收根节点(gc root),只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
栈帧里另一个非常重要的结构,叫操作数栈
栈可以使用数组或链表来实现,数组和链表是真实存在的物理结构(存储结构)
也就是虚拟机栈以栈帧为存储单元,而栈帧里又有栈!就是表达式栈即操作数栈
只要是栈,只能有入栈和出栈两个操作
入栈、出栈的对象为操作数!!
操作数是什么呢?
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。所以操作数就是指的临时中间结果或临时变量之类的
操作数栈,栈中的任何一个元素都是可以任意的Java数据类型
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
操作数栈和局部变量表一样,在编译之后就确定了栈的深度
这是字节码文件解析之后的样子,stack就是操作数栈编译之后确定的深度,locals是局部变量表编译之后确定的大小
编译之后还是在本地磁盘的,是生成字节码文件,要运行,那么就需要进行加载,这样二进制静态数据才会被加载到方法区,变成运行时结构
如果被调用的方法带有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令的地址!
Java虚拟机的解释引擎是基于栈的执行引擎,这里的栈就是指的操作数栈!
byte、short、char、boolean类型都是以int类型保存
执行引擎是负责把字节码执行转换为机器指令,然后CPU执行
框起来这个几个,涉及到操作数栈,iadd会首先出栈,然后CPU执行相加,CPU能执行的原因是因为执行引擎将字节码指令转换成了机器指令,上面这张图就是字节码指令
istore这些就是涉及到局部变量表
局部变量表用数组实现,在编译期就确定了数组的长度,局部变量表是数字数组
操作数栈,可以用数组或链表这两种真实结构来实现,在这里是用数组实现。在编译期间就确定了数组的长度
也就是说局部变量表和操作数栈都是用数组实现
栈顶缓存技术
基于栈式指令架构的虚拟机是使用的零地址指令,栈式内存层面的,为了JVM的跨平台性,用的栈式指令架构
而基于寄存器(与硬件挂钩),是一地址指令、二地址指令、三地址指令。
栈式架构指令集更小,但是由于是栈式的,那么完成一个操作,需要使用很多的入栈、出栈指令,所以完成一项操作的指令数比基于寄存器的架构多,那么内存读写次数也就更多
栈顶缓存:将栈顶元素全部缓存在物理CPU的寄存器中,降低对内存的读、写次数
动态链接
大部分字节码指令在执行的时候都需要进行对常量池的访问
帧数据区中就保持着能够访问运行时常量池的指针
动态链接就是指向运行时常量池的方法引用(这里的运行时常量池就是方法区中的运行时常量池)
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里,运行之后,class文件的常量池的信息就保存到方法区中的运行时常量池中了。
比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
可以理解为:
动态链接指向方法区中运行时常量池中的符号引用,从而得到直接引用
常量池里面是符号引用
动态链接是根据符号引用去常量池里面找到具体的位置,再根据符号引用得到直接方法引用
方法的调用
静态链接也叫早期绑定,由符号引用转换为直接引用,在编译期就能确定下来
动态链接叫晚期绑定
虚函数的特征就体现为在运行期才能够确定下来调用的是哪个方法!!!!
就是具备晚期绑定的特点!
如果Java程序中不想让某个方法拥有虚函数的特征,就可以使用关键字final来标识。
final标识就是不允许这个方法被重写,那么就是在编译期就确定了,没有重写方法,就是不具备多态的这种特点了。
子类对象多态性的使用前提
类的继承关系
方法的重写
静态方法、final方法、私有方法都不可以被重写,构造器不能重写!
非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法
静态方法、final方法、私有方法、实例构造器、父类方法都是非虚方法
剩下的方法都是虚方法,就是在编译期不确定具体调用的哪个方法,为什么不确定?就是因为涉及到方法的重写,这是多态性的体现
Java是静态类型语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之就是动态类型语言
JavaScript,python是动态类型语言
动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息
方法重写的本质
虚方法表是为了减少频繁地去找应该调用哪个方法的过程
在面向对象的编程中,会很频繁地使用到动态分派(可以理解为去找应该执行哪个方法),为了提高性能,JVM采用在类的方法区建立一个虚方法表来实现,非虚方法不需要,因为非虚方法在编译就可以确定是执行哪个方法。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口
子类重写过父类的方法,或者实现了接口的方法,那么虚方法表中有实际入口,指向子类自己实现的方法,如果没有重写的那些虚方法,就是指向父类的方法或者接口的方法
换句话说,虚方法表就有方法具体的实际入口
虚方法表是在方法区的,每个类都有!
虚方法表在方法区,是运行时,类的加载过程才被创建,开始初始化
运行字节码文件,才会有虚方法表
因为是虚方法,编译时,不知道在运行时具体会调用哪个方法,只有在运行时有了创建了虚方法表才知道。
虚方法表,代替了每次都在常量池中找符号调用,如果找不到就继续从下往上对父类进行搜索的这个过程
字节码文件的常量池对应于方法区的运行时常量池(进行类的加载后),常量池里有所有变量和方法的符号引用,根据符号引用转换为调用方法的直接引用,这叫做动态链接
虚方法表在类加载的链接阶段被创建并开始初始化
方法返回地址
方法返回地址存储的是调用该方法的PC寄存器的值。
PC寄存器存储的是要执行的下一条字节码指令的指令地址
PC寄存器这个下一条要执行的指令的指令地址给了方法返回地址
交给执行引擎,去执行后续的操作
一个方法的结束有两种方式
- 正常执行完成---正常退出出口
- 出现未处理的异常,非正常退出----异常退出出口
无论哪种方式退出,在方法退出后都返回到该方法被调用的位置!
方法退出后,返回到该方法被调用的位置,递归就是要这么来分析
方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址
通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息
通过异常完成退出的,不会给他的上层调用者产生任何的返回值
正常完成出口和异常完成出口的区别在于:
通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
不考虑方法区的情况下,最大的就是堆空间
方法返回地址相当于是给上层调用者产生的一个返回地址,是调用者PC寄存器保存的下一条指令的指令地址,是给执行引擎执行的,相当于是上层调用者调用完一个方法了,得到一个返回地址了,该接着往下执行了
何为线程安全?
如果只有一个线程才可以操作此数据,则必是线程安全的
如果有多个线程操作此数据,则此数据是共享数据,如果不考虑同步机制的话,会存在线程安全问题
方法中定义的局部变量是否是线程安全的?
方法中的局部变量r颗是在内部产生内部消亡的,就是线程安全的,
不是内部产生的,或者是内部产生的,但是返回到外面了,就不是线程安全的。
本地方法接口
什么是本地方法?
就是用native来修饰的Java方法就是本地方法
没有方法体!但不是抽象方法!没有方法体是因为方法体由C、C++实现
本地方法就是一个Java调用非Java代码的接口
本地方法接口的作用就是融合不同的编程语言为Java所用,初衷时融合C\C++程序
就是说方法体的具体实现不由Java来实现,就用native标识,就是本地方法
为什么要使用本地方法
有时Java应用需要与Java外面的环境交互。这是本地方法存在的主要原因
如果我们在某些需要很在乎执行效率的场景,要考虑调用C和C++
虽然Java代码运行在jvm上,但是jvm并不是真实的操作系统,是虚拟机,它要依赖真实的操作系统,操作系统层面,就要依赖C实现,JVM一些部分就是用C写的
引导类加载器就是C\C++编写的,属于JVM的一部分
用native标识来表示对本地方法的调用
本地方法栈
Java虚拟机栈管理Java方法的调用,本地方法栈管理本地方法的调用
虚拟机栈的存储单位是栈帧,一个栈帧就对应于一个方法,里面有局部变量表、操作数栈、动态链接、方法返回地址
一个栈帧就对应于一个方法的调用
栈管运行,堆管存储
本地方法栈和虚拟机栈、PC都是线程私有的。堆和方法区是线程公用的。
本地方法栈和虚拟机栈一样,允许被实现成固定或者是可动态扩展的内存大小
栈层面,线程请求分配的栈容量超过了栈允许的最大容量,是StackOverflowError
内存层面不够了,是outOfMemoryError
Hotspot JVM 有本地方法栈,但是不是所有的Java虚拟机都有本地方法栈。
Hotspot JVM将本地方法栈和虚拟机栈合二为一
堆
堆的核心概述
在整个运行时数据区,不考虑方法区,堆是最大的空间,因为方法区可以设置,可以通过本地空间实现
堆空间主要用来存储对象实例的
方法区、堆对于线程来说是共用的,但是对于一个进程来说,是唯一的。
一个进程对应于一个JVM实例,**一个JVM实例就有一个运行时数据区。**所以一个JVM实例就对应于一个堆内存
Runtime是单例的,是饿汉式创建的。
Runtime里面就有一个堆、一个方法区。所以对于一个进程来说,堆和方法区是唯一的。
但是一个进程有多个线程,所以多个线程要共享这个进程的堆空间和方法区
每一个线程各自拥有程序计数器、本地方法栈、虚拟机栈。共用他们所属的进程的方法区和堆。
共享就涉及到线程安全问题
一个jvm实例只存在一个堆空间,堆是Java内存管理的核心区域
Java堆空间在JVM启动的时候被创建。
JVM是什么时候启动?
JVM是在Java程序运行时,JVM实例通过引导类加载器创建,即启动
堆空间在jvm启动时候创建,其空间大小也就确定了。
堆是垃圾回收、性能调优的重要区域
堆内存的大小是可以调节的。
-Xms -Xmx
分别设置堆的初始大小和最大大小通过两个main方法入口跑两份Java代码,就是启动两个进程!一个进程里有多个线程,main是主线程,还有垃圾回收线程,还有异常处理线程,还可以自定义线程!
而一份Java程序(只有一个main方法入口)视为一个进程!跑两个就是两个进程!
一个进程对应于一个JVM实例,那么对应于一个运行时数据区,对应于一个堆空间、方法区
Java虚拟机规范规定,堆可以处于物理上不连续的内存空间内,但在逻辑上它应该视为连续的。
物理内存和逻辑内存可以建立一个映射表,可以对物理上不连续的内存在逻辑上看作是连续的。
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区
一个进程中的线程共享这个堆的所有空间吗?
不是的,堆中还有线程私有的缓冲区
所有的对象实例以及数组都应该在运行时分配在堆上
几乎所有的对象实例都在这里分配内存---almost
为什么是几乎呢?因为还有可能在栈上分配
数组和对象可能永远都不会存储在栈上,因为栈帧中保存引用(栈帧的局部变量表里保存的基本数据类型是数字、对象引用、返回值类型也可以用数值来表示,所以局部变量是数字数组),这个引用指向对象或者数组在堆中的位置
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾回收的时候才会被移除
比如某个方法执行完,调用完之后,栈帧要出栈,那么栈帧中的局部变量表中的对象引用,指向堆空间中的“指针”就没有了,指针没有了之后,堆空间中的对象实例就被认为是垃圾了。
但是认为是垃圾的这个过程,要等到GC的时候判断,来确定堆空间的对象实例没有对象引用指向了。
也就是栈帧出栈,没有指针指向堆空间,对象实例变成垃圾,垃圾回收机制判断,回收掉对象实例
栈帧出栈之后,对象实例不是立即被垃圾回收回收掉的。不是立马回收的!不能老是执行垃圾回收,GC频率高,就影响用户线程执行
垃圾回收线程执行的时候,需要用户线程停止。
所以我们会优化使得堆空间大一点,减少GC的次数
所以并不是栈帧出栈,就立即回收掉堆空间的对象实例
内存细分
jdk7及以前,堆内存逻辑上分为三部分
新生区
养老区
永久区(永久代)
jdk8及以后,堆内存逻辑上分为三部分
- 新生区
- 养老区
- 元空间
虽然逻辑上分为三部分,但是实际上堆空间不包括永久代
永久代和元空间都看作是方法区具体的落地实现。
所以这一章堆空间,主要涉及到新生区和养老区
堆空间逻辑上有三个区,实际上元空间或者永久代是方法区,实际上堆空间目前来说只有新生区和养老区
新生区具体又分为:
伊甸园区
survivor零区(from区)
survivor一区(to区)
-Xms -Xmx
分别设置堆的初始大小和最大大小,只管新生代、养老代两个区。
设置堆空间内存大小与OOM
一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常
栈溢出是线程申请的栈容量大小超过了栈的容量
而OOM是内存不够了。
比如本地方法栈在扩展的时候无法申请到足够的内存,或者在创建栈的时候,因为内存不够,不能创建本地方法栈,会报OOM
总之,栈空间溢出了,报栈溢出,内存不够了,报OOM
-Xms用来设置堆空间(年轻代、老年代)的初始内存大小
-X 是jvm的运行参数
ms 是memory start
-Xmx 用来设置堆空间(年轻代、老年代)的最大内存大小
开发中,建议将-Xms和-Xmx两个参数配置相同的值,
堆空间频繁地扩容和释放会造成系统的不必要的压力
其目的是为了在Java垃圾回收时,避免在GC的时候去调整堆内存的大小,造成系统不必要的压力
堆空间的新生代分为伊甸园区,survivor 0区和survivor 1区
如果我们要存储对象的话,伊甸园区可以存储,survivor 0区和survivor1区只能选择一个存储,涉及到垃圾回收的复制算法,survivor 0区和survivor1区始终有一个区是空的
查看堆空间设置的参数
OOM也有多种情况,Java heap space只是其中一种,超出了堆空间内存范围
new的对象实例会被分配到堆空间中,伊甸园区可以分配空间存储对象实例,幸存者区一定有一个是空的
old区满了之后,就会报OOM了。
年轻代与老年代
存储在JVM中的Java对象可以被划分为两类:
一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
另外一类对象的生命周期很长,在某些极端情况下还能够与JVM的生命周期保持一致
每次进行GC的时候,生命周期较短的对象,会被回收
生命周期很长的对象,就放在老年代,老年代不会经常进行是否需要进行垃圾回收的判断
年轻代
- Eden
- Survivor 0 (from)
- Survivor 1 (to)
老年代
对象最先创建的一个位置,就叫伊甸园
伊甸园中的Java对象,在GC的时候有的会被回收掉,有的还存活了,就放在幸存者0区或1区
-XX:NewRatio
设置老年代与新生代的比例,默认值是2一般情况下不会调这个参数
如果明确这个程序中有很多对象生命周期都很长,那么把老年代的比例调得更大些
-XX:-UseAdaptiveSizePolicy
关闭自适应的内存分配策略-XX:SurvivorRatio
:设置新生代中Eden与Survivor区的比例-Xmn:
设置新生代的空间的大小(一般不设置)几乎所有的Java对象都是在Eden区被new出来的
绝大部分的Java对象的销毁都在新生代进行了。
新生代对象分配与回收过程
首先把对象分配到伊甸园区(几乎所有的Java对象都是在Eden区被new出来的,不是所有的)
一旦伊甸园区放满之后,伊甸园区放不下了,这时候就要进行YGC/MinorGC,这个时候进行年轻代的垃圾回收,此时用户线程停止
触发年轻代的垃圾回收之后,就要进行判断,判断伊甸区的对象谁是垃圾谁不是垃圾
如果是垃圾,就被回收掉,如果不是垃圾的对象,就被放在幸存者的to区,从Eden区放在幸存者区的对象,age赋值为1
接着会继续放对象在伊甸园区,伊甸园区满了之后会触发YGC
每次执行YGC之后,不是垃圾的对象会被放在幸存者to区,即幸存者区空的那个区域(下一轮YGC,此时的to区又变成from区,空的那个叫to区),总之空的叫to区
也就是说对象可能会交替放在s0区和s1区
age达到15(幸存者区晋升到老年代的阈值,可以进行设置)的对象,做一个晋升,晋升到老年代,判断对象从幸存者区到老年代,会用到年龄计数器
伊甸园区满的时候,触发young GC, 幸存者区满的时候,不会触发YGC
触发YGC之后,会将伊甸园区和幸存者区一起进行垃圾回收
幸存者区不触发,但是不意味着幸存者区不进行垃圾回收
在进行YGC的时候,绝大部分对象都已经变成垃圾,需要进行回收了,只有很少的对象会进入幸存者区
总结:
伊甸区满---触发YGC
Minor GC/Major GC/ Full GC
MinorGC = YOUNG GC
针对于老年代的垃圾回收叫Major GC,如果老年代的垃圾回收完了之后,对象仍然放不下,就会出现OOM异常
调优就是希望出现垃圾回收的情况少一些
区分
HotSpot VM的GC实现:
部分收集(Partial GC)
新生代收集(young gc / minor gc)
只是新生代的垃圾收集,新生代包括Eden,s0、s1
老年代收集 (old gc/ major gc)
只是老年代的垃圾收集
目前只有CMS GC 会有单独收集老年代的行为(但是分类要这么来划分)
注意,很多时候major gc和full gc会混淆使用,需要具体分辨是老年代回收还是整堆回收
混合收集(mixed GC)
收集整个新生代以及部分老年代的垃圾收集
整堆收集(full gc)
就是收集整个Java堆和方法区(新生代、老年代、方法区)的垃圾收集
young gc的触发机制
- eden区满(survivor满不会引发gc)
- 因为Java对象大多具备朝生夕灭的特性,所以minor gc非常频繁,一般回收速度也比较快
- minor gc会引发stw,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
major gc触发机制
full gc触发机制
出现OOM之前,一定经历过full gc,因为老年代空间不足会触发full gc,当对象要往老年代放时,但老年代空间不足,那么触发full gc,如果full gc之后还是放不下,那么就会出现OOM
这里的full gc 和 major gc没有加区分
堆空间分代思想
为什么需要把Java堆分代?
不同对象的生命周期不同,大多数对象是临时对象
新生代:eden、survivor0、survivor1
老年代:存放新生代中经历多次GC仍然存活的对象,有一个阈值,age = 15,如果对象太大,伊甸园区放不下,会判断老年代是否放得下,如果老年代放得下,会直接放到老年代,另外如果survivor区满了,也可能放到老年代。老年代如果还放不下,那么会触发full gc或者说是major gc,如果经历full gc或者major gc后还放不下,那么就会出现OOM
分代就是为了优化GC性能,有minor gc、major gc、 full gc
minor gc就是高频触发的,新生代是频繁回收的
老年代回收就降低,就实现优化,因为回收老年代花费时间较多
优化的方向就是朝着减少gc次数
内存分配策略
优先分配到Eden
大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象
长期存活的对象分配到老年代
动态对象年龄判断
- 如果Survivor区中相同年龄的所有对象大小的总和大于survivor区的大小的一半,年龄大于等于该年龄的对象可以直接进入老年代,无需等到那个阈值
空间分配担保
对象分配过程:TLAB
TLAB是线程缓冲区
在并发环境下从堆区中划分内存空间是线程不安全的
堆是线程共享的,所以线程不安全
什么是TLAB
对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域,包含在EDEN空间内
多线程同时分配内存时,使用TLAB可以避免一系列的线程不安全问题
同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
所以堆空间所有部分都是线程共享的吗?
不是,堆空间的新生代的伊甸园区里有TLAB,是线程私有的,JVM会把TLAB作为为对象分配空间的首选,可以避免线程安全问题,如果TLAB放不下,那么会在伊甸园的非TLAB区域为对象分配空间,这时要采用加锁机制确保数据操作的原子性。
换句话说,如果对象能在伊甸园区的TLAB区域分配空间,则没有采用加锁这种机制。因为使用加锁机制会影响空间分配的速度,所以TLAB是为对象分配空间的首选
分代的一个主要目的就是为了让对象存储在新生代,因为大多数对象是临时对象,通过新生代的ygc,就可以把垃圾回收掉,提高效率
gc频率变高会影响执行效率,影响用户线程
调优就是要减少gc频率
堆外存储技术
逃逸分析,一个对象没有逃逸出方法的话,那么就可能被优化成栈上分配
TAOBAO VM, 将生命周期较长的Java对象从heap中移至堆外(GCIH)
GC不能管理GCIH的Java对象,从而达到降低GC的回收频率和提升GC的回收效率的目的
为什么要堆外存储?
为了降低GC频率。提高效率
逃逸分析
如何快速地判断是否发生了逃逸分析,就看new的对象是否有可能在方法外被调用
开发中能使用局部变量的,就不要在方法外定义,更不会考虑静态的问题
如果定义成属性,就涉及到GC的问题
逃逸分析是需要开启的,没有开启的情况下,即时没有发生逃逸,那么对象也是在堆上分配空间
开启逃逸分析可以减少GC次数,因为将符合条件的对象(即没有发生逃逸的对象)分配到栈上,栈上的对象就不需要垃圾回收了,提高效率,减少GC
堆外存储的目的就是为了降低GC频率
使用逃逸分析,编译器可以对代码做如下优化
栈上分配,减少GC
同步省略,Java虚拟机通过逃逸分析,发现这块代码只能被一个线程访问到,那么在JIT编译阶段就会把上锁的这块代码给取消掉同步锁
分离对象或标量替换----把聚合量替换成标量,也就是把对象替换成局部变量,局部变量就可以分配到栈空间中,目的还是减少GC,标量替换可以大大减少堆内存的使用,因为一旦不需要创建对象了,那么就不再需要分配堆内存了
标量替换默认是打开的,就是将对象打散了分配到栈上
逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程
但是逃逸分析目前并不成熟
淘宝的GCIH也不是栈上分配,而是放到本地内存中
用synchronized关键字同步代码块,监视器锁一定要保证是同一个
年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器回收
老年代放置长生命周期的对象,也有垃圾回收
老年代回收(full gc)是开发中要尽量避免的
老年代防止长生命周期的对象:
通常都是从survivor区域筛选拷贝过来的Java对象
普通对象分配:
- 首先TLAB
- 如果对象较大,试图直接分配到Eden其他位置上
- 如果对象太大,完全无法在新生代找到足够的连续空闲空间,JVM就会直接分配到老年代
方法区
栈、堆、方法区的交互关系
堆空间和方法区是线程共享,要考虑线程安全问题
元空间相对来说是比较稳定的,GC不会像堆空间那么频繁
GC是较多回收新生代,较少回收老年代,基本不动方法区
程序计数器不会报异常,也不存在GC
整个类的结构会加载到方法区
new的对象放到对空间中
如果这行代码在方法内写的,那么person这个对象引用就作为局部变量放到虚拟机栈的栈帧的局部变量表中
方法区主要存放的是方法和构造器的字节码指令、类的信息(结构)
方法区是随着虚拟机启动,创建好的
虽然方法区在逻辑上是堆空间的一部分(在Java虚拟机规范上),但是堆的话,有压缩算法、GC。但是方法区可以不压缩,也可以没有GC,就是说方法区可以独立于堆空间存在
jdk8没有限定方法区是不是堆的一部分
在Java虚拟机的具体实现上,方法区是和堆分开的,看作是独立于堆的一块空间
方法区的理解
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都是可以是不连续的
方法区的大小和堆空间一样,可以选择固定大小或者可扩展
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多了的类,导致方法区溢出,虚拟机同样会抛出OOM:jdk7及以前是PermGen space,JDK8及以后是Metaspace
方法区可以看过接口、规范,永久代和元空间可以看作方法区的具体落地实现
对hotspot来说,方法区和永久代是等价的,对于其它虚拟机实现来说,方法区和永久代不等价。我们面试答题针对于hotspot来说就可以了
使用永久代的时候,仍然是使用Java虚拟机的内存,更容易超过OOM
在jdk8及以后,使用元空间作为方法区的具体落地实现,替代了永久代
永久代是使用Java虚拟机的内存,而元空间是使用的本地的内存,这样显然更不容易出现OOM
元空间和永久代的最大区别就是,元空间不是使用的Java虚拟机的内存,而是使用本地的物理内存(这里的物理内存不是指硬盘,是指内存,内存都是指运行内存)
元空间依赖本地内存
设置方法区大小与OOM
没有加参数的情况下,方法区会使用动态扩展的形式
在jdk1.8及以后,采用元空间,使用本地内存,那么默认初始大小是21M,上限默认是本地内存大小,如果方法区占满了,会进行一个动态扩展,直到把本地内存占满,然后如果还有类加载,那么会报OOM异常
可以设置方法区的初始大小和上限,也可以采用默认,默认的动态扩展会直到占满整个物理内存
如何解决OOM
通过内存映像分析工具进行分析,重点确认内存中的对象是否是必要的, 也就是要分清楚到底是出现了内存泄漏还是内存溢出
内存泄漏:如果是内存泄漏,导致垃圾回收不掉,导致内存溢出。就是堆空间中的对象回收不掉,因为有栈空间中的对象引用指向堆空间中的对象,但是不用这个对象,导致堆空间中的对象回收不掉。如果内存泄漏,通过工具查看泄漏对象到GC roots的引用链,于是就找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾回收器无法自动回收他们的。
内存溢出:如果不是内存泄漏,导致垃圾回收不掉,导致内存溢出,就检查虚拟机的堆参数
方法区的内部结构
方法区里面主要放类信息和运行时常量池
这个类信息指类、接口、枚举类、注解等
《深入理解Java虚拟机》对方法区存储内容的描述:
它用于存储已被虚拟机加载的类型信息、常量(运行时常量池)、静态变量、被JIT即时编译器编译的缓存代码等
方法区也会记录字节码文件是使用哪一个类加载器加载进来的,类加载器在方法区里也是有记录的。(垃圾回收的时候,如果某个类加载器没有用了,类加载器对应的类也会被回收)
也会记录某个类加载器,加载过哪些字节码文件
从字节码角度来看,构造器和普通的方法都叫做方法,在源代码层面,构造器和方法是分开的
类型信息
方法信息
静态变量
静态变量,没有final修饰:
- 在类加载的链接的准备阶段,默认赋初值为0,在初始化阶段才赋值为1
静态变量,用final修饰
- 在类加载的链接的准备阶段,或者说编译阶段,就已经赋值为2了。
运行时常量池
要注意运行时常量池和常量池的区别
运行时常量池是运行时结构,而常量池是静态结构
方法区,内部包含了运行时常量池
字节码文件,内部包含了常量池
要弄清楚方法区,需要理解清楚字节码文件,因为加载类的信息都在方法区
要弄清楚方法区的运行时常量池,需要理解清楚字节码文件中的常量池
字节码文件的常量池表,包含了各种字面量和对类型、域和方法的符号引用,符号引用转换为直接引用是发生在类加载过程的链接的解析阶段。(动态链接就是指向运行时常量池的符号引用,从而得到直接引用)
一份很简单的代码,都要加载很多结构,这些结构不直接存到字节码里,那么就存到常量池,字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,将符号引用转换为直接引用
说简单点:
字节码文件的指令中会有符号引用,指向常量池的符号引用,所有的变量和方法引用都作为符号引用保存在class文件的常量池里
常量池,可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
运行时常量池是方法区的一部分,常量池是字节码文件的一部分
常量池是字节码文件的一部分,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中,它们是对应的关系!
运行时常量池中包含多种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用,此时不再是常量池中的符号地址了,这里转换为真实地址
总结:方法区存的结构
- 类型信息
- 运行时常量池
- 静态变量
- 域信息
- 方法信息
- JIT编译器编译的代码缓存
局部变量表,如果放的基本数据类型,就是放的变量真实的值,如果放的引用类型变量,那么就放的是地址,都可以用数字来表示,所以局部变量表是一个数值数组
方法区的演进细节
jdk1.6及以前,有永久代,静态变量存放在永久代上
jdk1.7,有永久代,但是静态变量和字符串常量池保存在堆中
jdk1.8及以后,无永久代,用元空间作为方法区的实现,字符串常量池、静态变量仍然在堆,但是元空间中有类型信息、运行时常量池、方法信息
(字符串常量池和运行时常量池不一样)
永久代为什么要被元空间替换?
- 很难确定为永久代设置的大小,空间小了,容易引起full gc,会拖慢程序的性能
- 元空间和永久代之间的最大区别在于:元空间并不在虚拟机中,而是使用本地运行内存,默认情况下元空间的大小仅受本地内存限制
- 对永久代调优困难
方法区的垃圾回收主要回收废弃的常量和不再使用的类型
StringTable(字符串常量或者说字面量)为什么要调整到堆空间中?
因为永久代的回收效率很低,永久代的回收是在full gc的时候才会触发,而minor gc是高频触发的,而full gc是低频触发的(调优就是为了减少gc),所以导致StringTable的回收效率不高
而我们开发中有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆里,能及时回收内存
full gc是在老年代空间不足的时候会被触发
静态引用(静态变量名)对应的对象实体始终都存在堆空间
前面说的静态变量存在的位置有一个变化,在1.6是永久代,1.7、1.8是在堆空间,这是说的静态变量名!(右边的对象实体始终在堆空间)
方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容
- 运行时常量池中废弃的常量
- 不再使用的类型
方法区的垃圾回收效果可能不好,但是是有必要的
常量池中主要存放两大类常量
- 字面量
- 符号引用
要想判断一个类不再使用,需要同时满足下面三个条件:
对象的实例化布局与访问定位
创建对象的方式
创建对象的步骤
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存
意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式叫空闲列表
指针碰撞对应标记压缩算法,空闲列表分配对应于标记清除算法(CMS这个垃圾收集器就用的标记清除算法)
采用什么方式来为对象分配内存,还要取决于用的哪种垃圾收集器,是用的标记压缩算法还是标记清除算法。
第四步是对属性的默认初始化
第六步是对属性的显式初始化,包括代码块和构造器中对变量的初始化。是在<init>这一步中。要区分<init>和<clinit>,后者是类加载过程的初始化阶段,前者是对应于类里面的构造器
对象实例化的过程
- 加载类元信息(判断类是否加载、链接、初始化)
- 为对象分配内存(要分内存空间是否规整,规整对应于指针碰撞,不规整基于空闲列表)(垃圾回收分别对应于标记压缩算法和标记清除算法)
- 处理并发安全问题
- 对属性的默认初始化
- 设置对象头的信息
- 对属性的显式初始化(对对象真正意义上的初始化)
new的对象放在堆空间中的,那么这个堆空间中的对象有哪几部分?
运行时元数据Mark Word格式:
对象的访问定位
HOTSPOT采用的直接指针的方式,即通过栈上的对象引用指向堆中的实例,堆中的实例对象有一部分叫对象头,对象头里有类型指针指向方法区中的对象的类型数据(即类型信息),这种方式没有在堆空间专门开辟空间来记录句柄
直接内存
运行时数据区是虚拟机内存的概念
直接内存不属于虚拟机内存,也不包含在jvm规范里
方法区的具体实现元空间就是用的直接内存,也就是本地运行内存,是Java堆外的,直接向系统申请的内存空间,来源于NIO
Java堆、栈都是虚拟机内存层面的
通过缓冲区操作本地内存
ByteBuffer的allocateDirect可以直接分配本地内存空间
出于性能考虑,读写频繁的场合可能会考虑使用直接内存
读写文件,使用非直接缓冲区,需要与磁盘交互,需要由用户态切换到内核态。导致效率低
使用直接缓冲区直接和物理内存进行交互,没有用户态和内核态copy的过程
直接内存也会导致OOM异常
直接内存分配回收成本较高,不受JVM内存回收管理
直接内存大小可以通过MaxDirectMemorySize设置
执行引擎
执行引擎概述
Java虚拟机粗略角度来分可以分为三层
- 类加载器子系统
- 加载
- 链接
- 初始化
- 运行时数据区
- 堆空间
- 方法区
- 虚拟机栈
- 本地方法栈
- 程序计数器
- 执行引擎
- JIT即时编译器
- 解释器
- 垃圾回收器
- 类加载器子系统
执行引擎是将字节码指令转换成机器指令,从而使得字节码文件能够被CPU所识别
执行引擎是Java虚拟机核心组成部分之一
虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力
区别是虚拟机的执行引擎是由软件自行实现的。(不受物理条件制约)
物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的。
JVM也是Java语言的跨平台性的原因
JVM主要任务就是装在字节码文件到虚拟机内部
字节码虽然是二进制的,字节码文件是二进制文件,但是字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于能够被CPU所识别的机器指令,字节码文件内部包含的仅仅是一些能够被JVM识别的字节码指令、符号表,以及其他辅助信息
那么,想让一个Java程序运行起来,执行引擎的任务就是将字节码指令解释、编译成为对应平台上的本地机器指令才可以,简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者
执行引擎的工作过程
Java代码编译和执行的过程
首先要明确,字节码文件只能够被JVM识别,而不能够被操作系统或者说CPU这种硬件层面识别,那么想要执行字节码文件,将字节码文件加载到JVM之后,JVM的执行引擎要将加载进来的字节码文件解释、编译成操作系统能够识别的机器指令。可以理解为执行引擎就是将字节码指令翻译成机器指令的翻译官
橙色的过程是由javac.exe完成的,形成一个线性的字节码二进制流
就相当于前端编译器,就是第一次编译,编译成.class字节码文件的过程
蓝色的就是第二次编译,将字节码指令编译成目标代码也就是机器指令
绿色的就是执行引擎的解释器逐行对字节码指令进行解释执行
Java语言是半解释型半编译型语言说的就是这个意思
橙色的过程跟Java虚拟机是没有关系的,是生成字节码文件的时候
字节码文件加载进Java虚拟机之后才和JVM有关系
什么是解释器?
解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”成对应平台的本地机器指令执行。也就是说解释器,也要将字节码指令翻译成机器指令
什么是JIT即时编译器?
虚拟机将字节码直接翻译成和本地机器平台相关的机器语言,没有说马上去执行
解释器的作用是保证响应的速度
编译器是保证执行的效率,对热点代码翻译成机器指令,进行一个缓存,存在方法区中。(好处就是去调用的时候,直接去调用缓存的已经编译好的机器指令,效率高)
解释器解释执行字节码指令,依赖程序计数器,每当解释执行完一条机器指令后,程序计数器会更新下一条需要执行的字节码指令的指令地址。
无论是解释执行还是JIT即时编译器进行编译,都要将字节码文件转换成操作系统能懂的机器指令
机器码、指令和汇编语言
机器码就是二进制编码表示的指令,叫机器指令码或机器语言。
就是0101这种的二进制符号
机器语言就是二进制,特点就是CPU可以直接读取运行,机器指令是和CPU密切相关的。
指令就是把机器码中特定二进制序列,简化成对应的指令,比如mov、inc
汇编语言也是不可以被硬件识别的,也是需要翻译成机器指令(二进制)。
高级语言就是可以理解为层层封装的,最终也要被翻译成机器指令,才能够被执行。比如JVM将字节码指令翻译成机器指令。一种是解释执行,一种是编译执行。
字节码文件是与硬件环境无关的,就是为了实现软件运行和软件环境。
这就是Java语言具有跨平台性的原因
字节码文件具有跨平台的特性
解释器
解释器在运行时采用逐行解释字节码执行程序的想法
执行引擎里的解释器也是要将字节码指令翻译成机器指令执行,并不是说只有JIT即时编译器,才会对字节码指令进行二次编译,编译成机器指令缓存起来,解释器和编译器都是翻译者,最终都要翻译成机器指令才能被硬件所识别
当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作
基于解释器执行已经是低效的代名词。
C和C++是编译和汇编两个过程,编译是把高级语言翻译为汇编语言的过程,汇编是把汇编语言翻译成机器指令的过程。
python是基于解释器执行,是动态语言,写很简单,但是执行效率就更低。写的语言更接近机器,那么执行效率就更高。
JIT编译器
即时编译,就是将热点的字节码指令编译成机器码,每次函数执行时,只需要调用编译好的机器码,因为编译好的机器码缓存在方法区中供调用
解释器的优势是立刻逐条解释执行字节码指令,响应速度快。
JIT编译器的优势是响应速度慢,因为需要编译成机器指令缓存这个过程,但是执行效率高。
当虚拟机启动时,解释器可以首先发挥作用,省去许多不必要的编译时间。
随着程序运行时间的退役,根据热点探测功能,将有价值的代码编译成为本地机器指令
JRockit VM 内部就不包含解释器,字节码全部依靠即时编译器编译后执行。
在服务端,对响应速度的要求并不是特别高,更关注执行效率、性能,所以可以砍掉解释器。客户端对响应速度才有要求
任何高级语言翻译成机器指令都要经过汇编这个过程。
什么时候让JIT做即时编译?
需要判断代码被调用执行的频率,频率高的代码被称为热点代码
JIT编译器编译字节码为本地机器指令,这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换
基于计数器进行热点探测
- 方法调用计数器用于统计方法的调用次数
- 回边计数器用于统计循环体执行的循环次数
两计数器之和超过阈值,触发OSR编译器请求
可以通过参数设置使hotspot虚拟机只使用解释器的模式或者只使用即时编译器的模式
hotspot虚拟机内置了两个JIT编译器,C1编译器和C2编译器,C1是client模式的编译器,C2是server模式的编译器,当电脑是64bits的操作系统,Java就是server模式的,就不用去设置了
C1的优化稍微弱一些,C2的优化更强一些
C1 JIT编译器进行简单的优化,耗时短,为了响应时间更短,因为client模式,更在乎响应时间
C2 JIT编译器花的时间更长,优化更强,响应时间更慢,但是优化好之后,执行效率更好。
没有说哪种编译器更好,他们是互补的关系,耗时短那么响应速度快,就优化稍差,执行效率稍差
耗时长,那么就优化好,执行效率高,但是响应速度慢,要辩证地来看他们之间的关系
C1是轻量级的优化
在jdk7之后的版本,使用server模式时,默认会开启分层编译策略,由C1编译器和C2编译器相互写作共同执行编译任务
总结:
- 一般来讲,JIT编译出来的机器码性能比解释器高(这里要注意解释器和编译器都是要把字节码转换成操作系统或者说硬件能识别的机器码,并不是只有编译器会转换成机器码,解释器也要将字节码指令转换成机器码)(执行引擎的作用就是充当将字节码或者说高级语言转换成机器码指令的翻译者)
- C2编译器即server模式下的JIT编译器,启动时长比C1编译器慢,但是执行效率更高。系统稳定执行之后,C2编译器执行速度远远快于C1编译器
编译器:
- AOT编译器(静态提前编译器),这是在程序运行之前,对字节码转换成机器码的编译操作
- JIT即时编译器,在程序的运行过程中,对热点代码(字节码)编译成可直接在硬件上运行的机器指令,并缓存到方法区中,是在程序的执行过程中来执行的。
- C1编译器(client模式)
- C2编译器(server模式)
- graal编译器
AOT编译器和JIT即时编译器都是将字节码指令转换成机器指令,只不过AOT在运行前,JIT在运行时
StringTable
String的基本特性
String类声明为final的,不可被继承
String变量的实例化
String s1 = "atguigu"; // 字面量的定义方式
String s2 = new String("hello");
String类实现了Serializable接口,Comparable接口
在jdk1.8底层用的是char数组,一个字符对应于两个字节16位
在jdk1.9及以后底层用的是byte数组
这是重大的一个改变
结论:String在jdk1.8之后不用char[]来存储,改成了byte[]加上编码标记,节约了空间,如果是ISO-8859/Latin-1,就用一个字节来存,如果是中文字符,还是用两个字节去存
StringBuffer和StringBuilder也都做了修改
String代表不可变的字符序列,简称不可变性
从以下三个方面体现:
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
- 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
通过字面量定义字符串的方式,字面量存储在字符串常量池中,字符串常量池中绝对不允许存储相同的字符串
字符串底层是数组,既然是数组,那么底层在创建的时候就已经确定好了长度,那么拼接的时候,显然是另外指令内存区域赋值,而不是直接在原数据后进行连接
而且数组的扩容也是需要另外造数组,而不是直接在原内存位置进行扩容
字符串常量池底层是一个固定大小的hashtable,hashtable又称为散列表,散列表在底层是jdk7及以前是采用数组+链表的形式存储,在jdk8及以后是采用数组+链表+红黑树的形式存储,所以在jdk7中数组就有固定长度是是60013
String的内存分配
- 在Java语言中,有8种基本数据类型和一种比较特殊的类型String,这些类型为了使他们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念
- 常量池就类似于一个Java系统级别提供的缓存,8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊,主要使用方法有两种
- 字面量声明的方式:String s1 = "atguigu"; // 会直接将String对象存储到字符串常量池中,当然字符串常量池底层是hashtable,字符串底层是byte或char数组
- 如果不是使用双引号声明的String对象,可以使用intern方法
- Stringtable为什么要从方法区的运行时常量池调整到堆空间中
- permSize默认比较小
- 永久代垃圾回收频率低,导致大量不用的字符串不能够及时回收,导致容易报OOM,字符串常量池放到堆里,能够及时回收
- 要注意区分运行时常量池和字符串常量池
字符串拼接操作
非静态方法的栈帧的局部变量表里面的第一个位置放的this,而静态方法则没有这个this
常量与常量的拼接结果在常量池中,原理是编译期优化(在生成字节码文件这个过程就直接认为字符串是拼接后的结果)
String s1 = "a" + "b" + "c";//等同于String s1 = "abc";
常量池中不会存在相同内容的字符串常量
只要其中有一个是变量,结果就在堆中非字符串常量池的区域(因为jdk7及以后,字符串常量池也在堆中而不在方法区中),变量拼接的原理的是StringBuilder
如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接后的结果(只要是new,那么就是在堆空间中新建了一个新的对象,当然是新的地址)
只要连接符两边任何一边出现了变量,先是new了一个StringBuilder,然后从局部变量表取出字符串,调用append方法
String s1 = "a"; String s2 = "b"; /* 执行细节: 1. StringBuilder s = new StringBuiler(); 2. s.append("a"); 3. s.append("b"); 4. s.toString() ---> 约等于new String("ab"); 当然就是在堆中非字符串常量池的区域新建了字符串对象 补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer */ String s4 = s1 + s2;
如果拼接的结果调用intern方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
intern()
javaEEhadoop 代表一个具体的字符串,进行一个说明
判断字符串常量池中是否存在JavaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址
如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回此对象的地址
字符串拼接操作:
- 如果拼接符号左右两边有变量,则使用的是StringBuilder
- 如果拼接符号左右两边都是常量或者常量引用,则仍然使用编译期优化,即非StringBuilder的方式,结果在字符串常量池中
只要使用了StringBuilder,通过append,然后toString的方式,那结果一定类似于new String(),最后结果在堆中非字符串常量池的区域
体会拼接符号
+
和StringBuilder的append()方式添加字符串执行效率StringBuilder的append()方式的效率要远高于
+
详情:
StringBuilder的append()方式,自始至终只创建过一个StringBuilder对象
使用String的字符串拼接方式,创建过多个StringBuilder对象,String对象(只要两边有变量),内存占用较大
进行GC操作也需要花费额外的时间
改进的空间:
在实际开发中,如果基本确定前前后后要添加的字符串长度的大小不超过某个阈值highLevel,建议使用构造器
StringBuilder stringBuilder = new StringBuilder(highLevel);
,这样的话,既可以采用StringBuilder的append方式,也可以尽量保证,不进行StringBuilder底层char数组的扩容操作。只要是数组涉及到扩容,都是要新建一个新的数组,然后复制数据
intern()的使用
如何保证变量s指向的是字符串常量池中的数据?
- String s = "aaa"; // 字面量方式
String s = xxx.intern();
// 不管前面是怎么做的,只要调用intern方法,返回的**引用都是指向字符串常量池里的字符串!**因为,即使字符串常量池里没有,调用了intern,也会在字符串常量池里造一个,再返回
new String("ab")
会创建几个对象?一个对象是:new关键字在堆空间创建的
另一个对象是:字符串常量池中的对象。字节码指令:ldc
new String("a") + new String("b")
呢?对象1:拼接操作:new了一个StringBuilder对象
对象2:new String("a"),new关键字在堆空间创建的
对象3:常量池中的"a",字节码指令:ldc
对象4:new String("b"),new关键字在堆空间创建的
对象5:常量池中的"b",字节码指令:ldc
深入剖析:StringBuilder的toString():
对象6:new String("ab")
强调一下:toString()的调用,在字符串常量池中,没有生成"ab"
解释:
第一道题:s.intern()之前,字符串常量池里已经有了"1"
s是指向堆空间中的地址,而s2是指向字符串常量池中的,无论是jdk6中永久代的字符串常量池,还是jdk7/8中堆空间的字符串常量池,s2和s指向的都是两个不同的地址,所以都是false
第二道题:
对于jdk1.6来说,s3是指向堆空间中的地址,s3.intern()之前,字符串常量池中没有"11",所以会新建一个新的"11"在字符串常量池(为什么会新建"11"字符串常量而不是引用呢,正是因为字符串常量池在方法区,而堆空间和方法区逻辑上是分开的),s4指向字符串常量池中这个新建的"11",所以s4指向字符串常量池,s3指向堆空间,结果为false
对于jdk7/8来说,s3仍然指向堆空间中的地址,而s3.intern()这行代码之前,字符串常量池中没有"11",所以会新建,但是由于字符串常量池已经挪动到堆中,新建之前,会检验,发现堆中已有
new String("11")
,字符串常量池中并不会直接新建一个"11"字符串对象,而是一个引用,直接指向堆中的new String("11")
,新建之后,s4指向字符串常量池中这个引用,这个引用又指向new String("11")
,而s3直接指向堆中这个new String("11")
如图
关键点:
jdk7之后字符串常量池在堆中,当堆中已有new String("11")时,并不会在字符串常量池中直接新建"11"字符串对象,而是一个引用指向这个new String("11")
无论是jdk1.6还是jdk7/8,因为是拼接操作的原因,用到了StringBuilder对象,最终在字符串常量池中不会生成"11",导致了后面调用s3.intern(),会新建,新建的时候由于字符串常量池位置的差异,造成了是否生成的是"11"还是一个引用的差异
用了intern(),在时间上和空间上都有节省
StringTable的垃圾回收和String去重
正是因为Stringtabe(字符串常量池)存在垃圾回收,所以使用intern,才会在空间上进行节省即优化
字符串常量池放在堆空间中,会进行垃圾回收,这也是要将字符串常量池从方法区移到堆空间的一个主要原因,因为如果串池在方法区中,那么只有在full gc的时候,才会进行回收,回收效率低,导致内存占用大,可能存在太多不用的字符串,如果串池放在堆空间中,那么在young gc或者说minor gc的时候,就可以对字符串常量池进行垃圾回收,效率高。
G1垃圾回收器的String去重指的是蓝色圈起来这个地方的去重
垃圾回收概述
什么是垃圾
垃圾回收算法分为垃圾标记阶段算法、垃圾清除阶段算法
jdk里默认使用G1垃圾回收器,ZGC垃圾回收器会替换G1垃圾回收器,ZGC现在不断地在优化
垃圾收集机制是Java的招牌能力,极大地提高了开发效率
哪些内存需要回收?
什么时候需要回收?
如何回收?
什么是垃圾?
在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,可能造成内存溢出
一个应用程序或者说一个进程对应着一个运行时数据区,一个运行时数据区就有一份方法区、堆空间等
进程结束,运行时数据区就没有了
为什么需要GC
对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完
除了释放没有用的对象,垃圾回收也可以清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一段,以便JVM将整理出的内存分配给新的对象,特别是新的对象比较大,需要一片连续的大的存储空间的时候
早期垃圾回收
在早期的c/c++时代,垃圾回收基本上是手工进行的
什么叫内存泄漏?
数据不用了,但是试图进行回收的时候,发现它还有相关引用,于是回收不掉
如果内存泄漏过多,导致内存空间持续上升,还没办法GC,最终造成内存溢出
Java垃圾回收机制
GC的作用区域是方法区和堆,只有在full gc的时候才会对方法区进行垃圾回收
而虚拟机栈是存在内存溢出,但是没有垃圾回收
程序技术器连溢出行为都没有
垃圾回收器可以对年轻代回收(minor gc),也可以对老年代回收(major gc),甚至是全堆和方法区的回收(full gc)
Java垃圾回收机制,从次数上讲:
频繁收集新生代
较少收集老年代
基本不收集方法区,只有在full gc的时候会对全堆和方法区进行垃圾回收,但是full gc很少会触发
垃圾回收算法
gc过程分为两个阶段,第一个阶段是标记阶段,第二个阶段是清除阶段
标记阶段就是要标记出哪些对象我们不再使用了,那么下一阶段就是清除
Object类里有一个finalize()方法,在对象被回收的时候,这个方法会被调用
标记阶段:引用计数算法
垃圾标记阶段就是判断对象是否存活,把垃圾标记出来
方法区的垃圾回收不具有普遍性,很多垃圾回收器都没有回收方法区
我们这里说的GC主要针对的是堆,堆里主要放的就是对象,所以这里垃圾回收主要针对对象,对垃圾的标记,也是对对象的标记,标记对象是死亡还是存活
在GC执行垃圾回收之前,需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法
引用计数算法,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况
只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收
优点:
实现简单,垃圾对象便于辨识,判定效率高,回收没有延迟性(不需要等到内存不够的时候才进行垃圾回收,只要发现引用计数器为0了,那就可以回收)。
缺点:
引用计数器增加了存储空间的开销
更新计数器伴随着加法和减法操作,增加了时间开销
没有办法处理循环引用,这是致命缺陷,导致Java垃圾回收器没有使用这类算法
python使用了引用计数算法
python如何解决的?
标记阶段:可达性分析算法
这是Java所选择的标记阶段的算法
也叫做根搜索算法、追踪性垃圾收集
在可达性分析算法中,只有能够被根对象集合(gc roots)直接或者间接连接的对象才是存活对象
gc roots 包括以下几类元素:
- 虚拟机栈中引用的对象
- 本地方法栈内JNI引用对象
- 方法区中静态属性引用的对象,比如引用类型静态变量
- 方法区中常量引用的对象,比如字符串常量池(String table)里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用
- 基本数据类型对应的Class对象
- 一些常驻的异常对象
- 系统类加载器
- 特别地,比如说eden区满,触发young gc,要回收eden区的对象,有老年代的对象的引用指向这个要回收的对象,那么老年代里的对象也是gc roots,因为当前的young gc并不涉及到老年代的对象。换句话说,在当前gc涉及范围之外的对象,指向了gc涉及范围之内的对象,那么之外的这部分对象也是gc roots
小技巧:
如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,这也是导致GC进行时,必须stw的一个重要原因,即使是号称不会发生stw的CMS收集器中,在枚举根节点时也是必须要停顿的。
不能在当前时刻标记好了,在下一时刻,引用又没有了,所以必须要保证一致性,在标记的那一刻,需要停顿,是这个原因
对象的finalization机制
一个垃圾对象被回收之前,会调用这个对象的finalize()方法,但是被回收之前,调用这个finalize()方法,可能使对象复活,于是不被回收,类似于刀下留人
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源的释放
永远不要主动调用某个对象的finalize()方法
虚拟机中的对象可能的三种状态:
- 可触及的:从根节点开始,可以到达这个对象,说明这个对象不是垃圾
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活
- 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
以上三种状态中,只有在对象不可触及时,才可以被回收
finalize()是在对象要被回收时才会被调用!
没有引用链,先判断是否有必要执行finalize()方法,并不是没有引用链就视为垃圾马上回收,在回收之前,要调用finalize()方法,而调用之前,要判断是否有必要调用
清除阶段:标记-清除算法
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存
执行过程:
当堆中的有效内存空间被耗尽,则触发垃圾回收,就会停止整个程序,被称为stop the world,然后进行两项工作,第一项则是标记,第二项就是清除。
标记:垃圾回收器从引用根节点开始遍历,标记所有被引用的对象(注意,标记的不是垃圾,而是垃圾的反面),一般是在对象的header中记录为可达对象
标记之后的记录是记录在对象头里
清除:垃圾回收器对堆内存从头到尾进行线性的遍历,如果发现某个对象在其header中没有标记为可达对象,则将其回收
(标记的过程有两步,一是没有引用链要标记,二是判断是否要finalize())
清除阶段:复制算法
显然这种算法清理出来的空闲内存是连续的,不会产生内存碎片
这个和将对象放到堆空间新生代的伊甸园区,伊甸园区满后,存活对象放到s0区,下一次伊甸园区满的时候,会触发minor gc,s0区中存活的对象又放到s1区,那么对象在s0区和s1区的这种交换,用到的就是复制算法
要注意s0区和s1区是会发生垃圾回收的,但是幸存者区满不触发垃圾回收,伊甸园区满才出发minor gc,minor gc会对幸存者区进行垃圾回收
为对象分配内存时,如果内存空间是规整的,那么采用指针碰撞的方式,对应垃圾回收算法是标记压缩算法和复制算法
如果内存空间不是规整的,采用维护空闲列表的方式,对应垃圾回收算法是标记清除算法
缺点显然就是存活对象在堆空间中的地址发生了改变,那么其他对象或者局部变量指向这个对象的引用地址就需要发生改变,这个引用是需要维护的,这也是开销
结论:复制算法,应用在所需要复制的对象并不用太多的情况下
因为新生代的对象死亡率很高,大多数朝生夕死,所以需要我们复制的存活的对象就相对少,于是在幸存者的from和to区应用复制算法
在老年代就不适合使用复制算法,因为老年代的对象大,存活时间长,复制算法的话,需要复制的对象就多,效率自然就不好
也就是使用了复制算法来进行GC之后,下一次为对象分配内存的时候,内存空间就是规整的,就可以使用指针碰撞的方式
清除阶段:标记-压缩算法
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。所以不适合使用在老年代,适合应用在新生代的幸存者区
标记-清除算法执行效率低下,而且在执行完后会产生内存碎片,碎片化很严重,那么大对象就在老年代中放不下(老年代要放大对象,因为如果小对象,首选是伊甸园区)
执行过程:
- 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象(在对象头中)
- 第二阶段将所有的存活对象压缩到内存的一段,按顺序排放
- 之后清理边界外所有的空间
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的,是否移动回收后的存活对象是一项优缺点并存的风险决策
分代收集算法,是基于:
不同的对象的生命周期是不一样的,因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率
年轻代回收频繁,那么回收次数多,那么需要每一次的回收效率高,所以可以使用复制算法
复制算法的效率只和当前存活对象大小有关,因为存活对象少,所以使用复制算法效率高
而复制算法内存利用率不高的问题,通过两个幸存者区的设计解决
老年代回收不频繁,对象生命周期长
一般用标记-清除或者标记-清除、标记-压缩的混合实现
增量收集算法
优点:
增量收集的算法仍是传统的标记-清除和复制算法,通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
也就是垃圾收集线程和应用程序线程交替执行(所以能减少系统的停顿时间),依次反复,直到垃圾收集完成。
缺点:
线程切换和上下文转换的消耗使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法
垃圾回收相关概念
System.gc()的理解
- 通过System.gc()可以主动调用垃圾回收器进行垃圾回收,是full gc
- System.gc()触发的gc不一定马上执行,无法保证对垃圾收集器的调用
- System.runFinalization() // 强制调用失去引用的对象的finalize()方法
内存溢出与内存泄漏
内存溢出
没有空闲内存,并且垃圾回收器也无法提供更多内存
在报OOM之前,通常会有一次GC的,GC之后还不够,才会报OOM
当然也不是任何情况都会有一次GC,例如分配一个超大对象,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OOM
内存泄漏
对象不会被用到了,但是GC又无法收集他们
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,最终出现oom异常,导致程序崩溃
内存泄漏有可能导致内存溢出,但是不是一定导致内存溢出
内存泄漏举例:
- 单例模式,生命周期和应用程序一样长,所以单例程序中,如果持有对外部对象的引用的话,这个外部对象是不能够被回收的,则会导致内存泄漏的产生。
- 一些提供close的资源未关闭导致内存泄漏
- 数据库连接没有关闭
- 网络连接socket没有关闭
- io流没有关闭
上面说的都是狭义上的内存泄漏:即某个对象不用了,但是由于还有其它的对象的引用链接着它,所以导致回收不掉
广义上的内存泄漏,由于实践上的疏忽,导致某些对象生命周期变得很长,导致OOM,这也叫内存泄漏
stop the world
指的是GC事件发生过程中,会产生应用程序的卡顿,卡顿的原因是GC发生时,Java用户线程会暂停
标记阶段的可达性算法,在枚举GC ROOTS的时候,会造成应用程序或者说用户线程的停顿
因为分析工作必须在一个能确保一致性的快照中进行,所以需要停顿
如果出现分析过程中,对象引用关系还在不停变化,则分析结果的准确性无法保证。
每次GC的时候都会出现STW
STW事件和采用哪款GC无关,所有的GC都有这个事件
CMS号称低延迟,仍然有STW事件,G1、ZGC垃圾回收器都有STW
STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉
垃圾回收的并行与并发
并发与并行二者得对比
并发,指的是多个事情,在同一时间段内同时发生了,在同一时刻只有一个事情发生
并行,指的是多个事情,在同一时间点上同时发生了
并发的多个任务之间是互相抢占资源的
并行的多个任务之间是不互相抢占资源的
只有在多CPU或者一个CPU多核的情况下,才会发生并行
否则看似同时发生的事情,其实都是并发执行的。
在垃圾回收方面的并发与并行
并行:
指的多条垃圾收集线程并行工作,此时用户线程仍处于等待状态
串行:
单个垃圾回收线程
程序暂停,启动垃圾回收器进行垃圾回收,回收完,再启动用户程序的线程(用户线程)
垃圾回收的并行和串行两种,对于用户线程和垃圾回收线程来说都是串行的,并行是说有多条垃圾回收线程同时执行,而不是说垃圾回收线程和用户线程并行
垃圾回收的并发
指用户线程和垃圾回收线程同时执行。
这里的同时执行指的同一时间段内
垃圾回收线程在执行时不会停顿用户程序的执行,但是这里是一段时间内,在某一刻可能会出现STW,出现STW那么用户线程就是暂停的。
安全点和安全区域
程序在执行的时候并非在所有的地方都可以停下来开始GC,只有在特定的位置才可以停下来GC,这些位置叫做安全点
安全点的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。
选择一些执行时间较长的指令作为安全点,如方法调用、循环跳转和异常跳转等。(提高执行效率)
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来?
- 抢先式中断(目前没有虚拟机采用)
- 主动式中断:设置一个中断标志,各个线程运行到安全点时,主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。可以把安全区域看作被扩展的安全点。
引用
强引用是默认的引用类型,普通系统99%以上都是强引用,也是最常见的。
强引用的对象是可触及的,垃圾回收器就永远不会回收掉被引用的对象。
强引用就是即使内存空间不足了,只要强引用还存在,那么就不应该被断掉,就报OOM
而软引用,在内存空间还足够的情况下,不会回收掉,但是系统内存空间不足了,就会把软引用的对象进行回收,这次回收之后,如果还是没有足够的内存,那么就报OOM
而弱引用,就是只要有垃圾回收,那么就回收掉这类引用
强引用是造成Java内存泄漏的主要原因之一,因为引用的存在,垃圾回收不能将不用的对象回收掉,所以造成内存泄漏
软引用、弱引用的对象都是可触及的对象
软引用用来描述一些还有用,但是非必须的对象,只被软引用关联着的对象,在系统发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收(第一次回收是回收的不可触及的对象),如果这次回收还没有足够的内存,才会抛出OOM
OOM和软引用的对象没有关系,因为在OOM之前,内存不足,就要将软引用的对象回收掉,回收掉之后,内存还不够,报OOM,这个时候软引用的对象已经被回收掉了。
所以OOM是由强引用,无法被回收掉造成的
另外强应用还会造成内存泄漏
软引用、弱引用都非常适合来保存那些可有可无的缓存数据,系统内存不足时,这些缓存数据会被回收,不会导致内存溢出
内存---本地----网络(三级缓存)
弱引用是发现即回收,软引用是内存不足即回收
WeakHashMap存的数据就是与弱引用关联的对象,在内存不足的时候可以及时进行回收
一个对象是否有虚引用的存在,完全不会决定对象的生命周期
如果一个对象仅有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收
无法通过虚引用获得被引用的对象。
设置虚引用的唯一目的是跟踪垃圾回收过程,比如能在这个对象被收集器回收掉时收到一个系统通知
软引用和弱引用都可以根据引用拿到对象,但是虚引用无法获得被引用的对象
一旦虚引用对象被回收,就会将此虚引用存放到引用队列中,可以从引用队列取出引用
终结器引用(了解)
垃圾回收器
GC分类与性能指标
串行回收与并行回收
串行回收是指只有单个垃圾回收线程,并行回收是指有多个垃圾回收线程同时工作
按照工作模式分
- 并发式回收:垃圾回收线程和用户线程交替工作(在一段时间内看起来就是用户线程和垃圾回收线程同时工作,在某一时刻仍然是只有一个线程在工作,这是并发的概念)
- 独占式回收:独占式垃圾回收器一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束,有很明显的stw
按照碎片处理方式
压缩式垃圾回收器:对存活对象进行压缩整理,消除回收后的碎片,主要看底层用什么垃圾回收算法,标记压缩算法和复制算法都是属于压缩
用压缩方式收集垃圾后,再分配对象空间使用:指针碰撞
非压缩式垃圾回收器:回收后,再分配对象空间使用:空闲列表(维护空闲列表本身也需要内存)
按照工作的内存区间来分
- 年轻代垃圾回收器:比如说复制算法的垃圾回收
- 老年代垃圾回收器:标记清除算法或者 标记清除算法与标记压缩算法的混合算法
评估GC的性能指标
吞吐量:运行用户代码占总运行时间的比例: a / (a+b)
总运行时间:用户程序的运行时间a + 垃圾回收的时间b
暂停时间:stw的时间,垃圾回收线程工作时,用户线程暂停的时间
收集频率:回收操作发生的频率
内存占用:Java堆区所占的内存大小
高吞吐量和低暂停时间,是矛盾的,是一对相互竞争的目标
如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收
不同的垃圾回收器概述
CMS是第一款并发的垃圾回收器
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器 : Serial Old、CMS、 Parallel Old
整堆收集器:G1
为什么要有这么多垃圾回收器?一个不好吗?
针对不同的场景,提供不同的垃圾回收器,还可以将垃圾回收器搭配使用,提高垃圾回收的性能,我们只是根据场景选择最合适的垃圾回收器,没有最好的垃圾回收器
Serial回收器:串行回收
Serial收集器采用复制算法、串行回收和stop the world机制的方式执行内存回收
除了新生代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器
Serial Old收集器也是串行回收和stop the world机制,只不过内存回收算法使用的是标记-压缩算法。因为复制算法不适用于老年代垃圾收集,复制算法最重要的就是存活的对象需要较少,这样复制的效率才高,垃圾回收的效率才高。
这个收集器是一个单线程的收集器,但它的单线程的意义并不仅仅说明它只使用一个CPU或一个垃圾线程去工作,而且是说它在垃圾回收的时候必须暂停其他用户线程,即stw
对于交互较强的应用而言,这种垃圾收集器不能接受,Javaweb应用程序中是不会采用这种串行垃圾收集器的。
ParNew回收器:并行回收
- 这是Serial GC的多线程版本
- 适合处理新生代的垃圾回收
Parallel 回收器:吞吐量优先
这个回收器吞吐量优先,吞吐量是指用户线程执行时间占总时间的比例
年轻代使用Parallel Scavenge回收器,采用复制算法、并行回收和stop the world
和ParNew收集器一样
ParNew收集器主要强调并行,Parallel收集器主要强调并行和吞吐量优先,并且Parallel收集器自成一派,G1收集器也是自成一派,所谓自成一派,是指收集器的底层框架和别的收集器不一样
垃圾回收的自适应调节策略,根据当前运行情况,进行性能监控,来调整吞吐量优先和低延迟优先,这也是Parallel和ParNew的区别
高吞吐量适合在后台运算不需要太多交互的任务,需要太多交互肯定是低延迟优先。
Parallel Old收集器采用了标记-压缩算法,也是基于并行回收和stw机制
CMS回收器:低延迟
第一款真正意义上的并发收集器,第一次实现了垃圾收集线程与用户线程同时工作
采用标记-清除算法(标记的不是垃圾,而是垃圾的反面,即存活的对象),也会stw
关注低延迟,看重服务的响应速度,标记清除算法时间就是居中的,标记压缩算法时间是最多的,复制算法所需时间是最快的。
CMS GC是老年代的GC,它不能和parallel gc工作,是因为他们的底层框架不一样
G1是兼具并行与并发的特点。GMS GC在jdk14的时候被移除
目前所有的垃圾收集器都做不到完全不要stw
初始标记和重新标记这两个阶段需要stw,暂停工作线程
最耗时的并发标记与并发清除阶段都不需要暂停用户线程的工作,所以整体的回收是低延迟的。
由于在垃圾回收阶段用户线程没有中断(并发),所以在CMS回收过程中,还应该确保用户线程有足够的内存可用。所以不能像其他收集器那样等到老年代几乎被填满才收集,而是当堆内存使用率达到某一阈值时,便开始进行回收
采用的是标记-清除算法,所以再次为对象分配内存时,不能采用指针碰撞,因为内存不规整。
G1回收器:区域化分代式
此回收器是分区的。
G1的目标是在延迟可控(低延迟)的情况下获得尽可能高的吞吐量
任何一个垃圾回收器包括ZGC都有暂停时间,在可控的暂停时间内,使得吞吐量尽可能高
G1是一个并行回收器(兼具并行与并发),把堆内存分割成很多不相关的区域(region),这些区域物理上是不连续的
使用不同的region来表示eden、s0区、s1区、老年代等
G1有计划地避免在整个Java堆中进行全区域的垃圾收集
G1在后台维护一个优先列表(根据价值维护了一个列表),每次根据允许的收集时间,优先回收价值最大的region
这种方式的侧重点在于回收垃圾最大量(回收价值最大的region)的区间
G1就是garbage-first,就是垃圾优先的意思,就是上面说的意思
G1是一款面向服务端应用的垃圾收集器
G1收集器在jdk9及以后取代了CMS回收器和parNew回收器的组合,还有parallel + parallel old的组合
CMS回收器在jdk9被标记为deprecated,在jdk14被remove
G1回收器的优势:
兼顾并行与并发
并行性:可以有多个GC线程同时工作(并行指的就是同一时刻,并发指的就是同一时间段内),有效利用多核计算能力
注意多个GC线程并行工作的时候,用户线程STW
并发性:G1部分工作可以和应用程序同时执行,因此不会在整个回收阶段发生完全阻塞用户线程的情况
分代收集,和之前的各类回收器不同,它同时兼顾年轻代和老年代
新生代
老年代
空间整合
在结构上不要求新生代、老年代这些结构是连续的,G1将堆空间分为了很多区域region,这些区域在物理上不是连续的,在逻辑上表示年轻代和老年代
region之间,是复制算法,但整体上实际可看作标记-压缩算法
这两种算法都可以避免内存碎片,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC
可预测的停顿时间模型
能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
选取部分region进行回收,既不是将新生代全部回收,也不是将老年代全部回收(正式因为了有了region这个概念,停顿时间才是可预测的)
G1跟踪各个region的垃圾的价值大小(所谓价值是回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,优先回收价值最大的region
G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS高
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,在大内存上则G1优势更大。平衡点在6-8GB之间
G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
region,化整为零的概念,为什么说暂停时间可预测,正是因为region的概念,并且有优先列表,指定时间之后,那么可以根据优先列表,选取部分region进行垃圾回收
所有的region大小相同,且在JVM生命周期内不会被改变
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理上连续的了。
一个region有可能属于eden、survivor、老年代,但是一个region在一个时刻只可能属于一个角色
G1垃圾收集器增加了一种新的内存区域,叫做humongous内存区域,简称H,主要存储大对象。如果超过1.5个region,那么就放到H区
如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储,为了能找到了连续的H区,有时候不得不启动full gc
为什么单独设置H区呢?
因为大对象默认放到老年代,但是有可能这个大对象的生命周期很短,用完之后,需要被及时回收,但是老年代的回收频率很低,所以会影响垃圾回收,宽泛意义上会造成内存泄漏,所以单独划分H区来存储大对象。(连续的H区,为了寻找到连续的H区,可能会触发full gc)
G1垃圾回收过程主要包括以下三个环节
- 年轻代gc
- 老年代并发标记过程
- 混合回收
有可能会出现full gc,比如设置最大暂停时间的时候,这个时间设置得很小,那么每次回收的region很少,随着内存占用越来越大,反而还会出现full gc,所以暂停时间并不是设置得越小越好
如果需要,单线程、独占式、高强度的full gc还是继续存在的,它针对GC的评估失败提供了一种失败保护机制,即强力回收
G1垃圾回收过程
年轻代GC
和之前介绍的一样,Eden区用尽,触发minor gc,并且minor gc也会回收幸存者区,但是幸存者区满了则不会触发minor gc,minor gc时,g1 gc暂停所有用户线程,启动多线程并行执行年轻代回收
然后从年轻代区间移动存活对象到幸存者区或者老年代,或者两个区域都涉及(大对象放不进幸存者区,则会放入老年代)
老年代并发标记
这个过程,年轻代的垃圾回收也在进行
堆内存使用达到一定值(默认45%)时,开始老年代并发标记
混合回收过程
标记完成,开始混合回收过程
对于老年代的回收,G1 GC从老年代移动存活对象到空闲区间,那么这些空闲区间也成了新的老年代,因为是按照region来回收的。这里就和以前不一样,以前是老年代满了触发major gc或者full gc,那么是针对整个老年代,而这里是根据设置的暂停时间,从后台维护的region优先列表选择价值最大的老年代region进行回收,region间的回收算法是复制算法,是选择的部分老年代region而不是整个老年代,要注意区分,同时年轻代的回收也在进行,所以是混合回收
young gc 在以上三个环节全都出现
记忆集
回收过程:
年轻代gc
并发标记过程
混合回收
七种垃圾回收器比较
-XX:+PrintGC 只打印出垃圾回收的行为,不列举出堆空间的使用情况
-XX:+PrintGCDetails 会列举出堆空间的使用情况
full gc会回收新生代、老年代、永久代
eden区和两个幸存者区,新生代大小显式的是eden区和一个幸存者区的大小的和,总是有一个幸存者区是空的,作为to区,显式的是新生代区可用的大小。
ZGC与Shenandoah GC目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟
ZGC几乎在所有地方都是并发执行的,除了初始标记是STW的,所以停顿时间几乎就耗在初始标记上,这部分实际时间是非常少的。