目录
一、JVM简介
JVM意为Java虚拟机。虚拟机是指通过软件模拟的具有完整的硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、WMware、Virtual Box。
JVM和其他两个虚拟机的区别:
- VMware等是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
- JVM则是通过软件模拟Java字节码的指令集,JVM只是保留了PC寄存器,其他的寄存器都进行了裁剪。
JDK、JRE、JVM的关联关系:
JDK和JRE的目录下都存在java.exe,通过其来运行class字节码文件,启动的时候就会创建一个JVM。JDK默认的JVM是HotSpot VM。
二、运行时数据区
1、堆(heap)
程序中所有创建的对象(数组也是对象)都存放在堆中。
- 对象指的是等号右边的;
- 变量指的是等号左边的。
注:jdk1.8,常量池(字符串常量池)也在堆中。
JVM参数设置:
- -Xms最小堆内存;
- -Xmx最大堆内存
堆里面分为两个区域(逻辑上):新生代和老生代。新生代放新建的对象,经过一定GC次数之后仍然存活的对象以及创建的大对象会放入老生代。
垃圾回收的时候会将Eden中存活的对象放在一个未使用的Survivor中,并把当前正在使用的Eden和Survivor清理掉。
2、方法区
用来存放被虚拟机加载的类信息(class字节码,进行类加载,把类加载到方法区,保存类的代码数据,同时堆中会生成一个类对象)、常量、静态变量、即时编译器编译后的代码等。
- 永久代(JDK1.7):属于Java内存进程;
- 元空间(JDK1.8):内存属于本地内存,不再受JVM最大内存的影响,与本地内存的大小有关。
- javac:编译器(编译Java代码为字节码文件);
- java:包含解释器(Java程序运行的时候,把class字节码文件翻译为机器码);
- 即时编译器:运行时,将热点代码翻译为机器码,之后就不用再进行翻译。
3、Java虚拟机栈
- Java虚拟机栈的生命周期和线程相同,
- 每个方法执行的时候都会创建一个栈帧,进行入栈操作,方法返回时,出栈;包含局部变量表、操作栈、动态链表、方法返回地址。
Java虚拟机栈:
- 局部变量表:存放八大基本数据类型、变量(等号左边)。局部变量表所需的空间在编译时分配。进入一个方法时,方法在帧内分配的局部变量空间是确定的,执行期间局部变量表的大小不会发生改变。
- 操作栈:每个方法会生成一个先进后出的操作栈;
- 动态链表:指向运行时常量池的方法引用;
- 方法返回地址:PC寄存器的地址。
4、本地方法栈
调用Java方法,就创建栈帧,放在线程的Java虚拟机栈中;如果调用其他的函数,就是用本地方法栈。
5、程序计数器
记录当前线程执行的行号。
6、内存布局中的异常问题
- 内存溢出(OOM):指存放数据的大小超出该区域的内存大小。运行时数据区域中,除了程序计数器,都可能发生内存溢出。内存溢出会导致整个进程都挂掉;
- 内存泄漏:线程生命周期太长,始终引用一些不使用的数据(没法进行gc垃圾回收),随着使用时间越来越长,不适用的垃圾就越来越多,可用空间越来越少——可能导致OOM。(解决方法:直接重启)
- StackOverflow:如果栈中的栈帧数量超过jvm规定,就会出现该异常。(递归使用不恰当)
三、类加载
1、类加载过程
(1)加载
加载class字节码到Java进程的内存中,在堆中创建类对象。
(2)验证
验证class字节码的规范(是否符合jvm规范)。
(3)准备
正式为类中的变量(静态变量)分配内存并对类变量进行初始化:
- static常量:赋值为初始值;
- static变量:赋值为缺省值;(Integer缺省值为null,int为0)
(4)解析
把常量池中的符号引用替换为直接引用:
- 符号引用:class文件(字节码)private static int x=123;此时进程还没有启动,无法表示变量x指向123(本质是指向内存地址),此时使用符号引用来表示这个指向关系;
- 直接引用:进程运行起来,x直接指向123(内存地址);
- 替换:把class文件常量池中的符号引用,替换为进程运行起来的运行时常量池中的直接引用。
(5)初始化
是类的初始化,不是对象初始化。初始化阶段,Java虚拟机真正开始执行类中编写的Java程序代码。初始化阶段就是执行类构造器方法的过程。
类对象的初始化:执行静态变量和静态代码块。
2、双亲委派模型
(1)什么是双亲委派模型
类加载的机制:双亲委派模型(jdk默认的类加载机制),其它机制(破坏双亲委派模型的其它机制)。
类加载器:包含四种,从上而下(上下关系,不是以extends继承来实现的,是逻辑上的上下关系):
- BootStrap ClassLoader 启动类加载器(主要负责加载Java核心类库,即%JRE_HOME%\lib目录)
- ExtClassLoader 扩展类加载器(主要负责加载目录%JRE_HOME%\lib\ext目录下的类)
- AppClassLoader 系统/应用类加载器(加载当前应用的classpath目录下的类)
- 自定义加载器
启动/扩展/应用这三类类加载器,只是加载jar目录下不同的jar包。
什么是双亲委派模型?
基于四种类加载器,按照从上到下的顺序,来加载类。类加载器收到类加载请求时,不会立即去自己加载,而是将这个请求向上传递,每一层都是如此,因此所有的加载请求最终应该在最顶层的启动类加载器中。因为类加载只执行一次,所以,上边找到,下边就不执行加载;上边没有找到,就交给下一级的加载。
(2)双亲委派模型的优点
- 避免重复加载类:如A类和B类都有一个父类C,当A启动起来的时候就会加载C,则B类进行加载时就不需要重复加载C类。
- 安全性:确保优先采用启动/扩展/应用类加载器来加载类。
如果自定义一个类加载,加载自定义的java.long.Object类:
【1】遵循双亲委派模型,就不会加载到自定义的Object,还是jre中的(安全);
【2】不遵循双亲委派模型,就可以加载到自定义的Object类(不安全)。【注】在jdk中,自定义类加载器时,进行了安全校验:加载类,如果类的全限定名以java./javax.开头的包,报错。
(3)破坏双亲委派模型
遵循双亲委派机制的类加载,某些场景下,可能无法实现知道需要加载的类名(jdbc中,jdk是无法知道数据库驱动类的类名)。
解决方案:破坏双亲委派机制。
常用的方案:SPI机制(jdk提供的一个ServiceLoader.load,约定了从jar包下/META-INF/services/文件名 中进行加载)
四、垃圾回收(GC)
- Java语言,不用自己分配内存,也不用自己回收内存(jvm中,实现了垃圾回收机制,自动回收)。
- 垃圾回收区域:堆(回收的主要区域)、方法区(保存类信息,静态变量,很少回收);
1、死亡对象判断算法
(1)引用计数算法
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器-1;任何时刻计数器为零的对象不能再被使用,即对象已死。但jvm中没有选用引用计数器来管理内存:引用计数器无法解决对象的循环引用问题。如下:
//此时instance的引用计数器会陷入死循环
Test test1 = new Test();
Test test2 = new Test();
test1.instance = test2;
test2.instance = test1;
(2)可达性分析算法
通过一系列称为“GC Roots”的对象作为起始点,从这些节点往下搜索,搜索走过的路径称为“引用链”,当一个对象到GC Roots没有引用链相连时,证明是“不可达的”。
引用的分类(强度依次递减):
- 强引用:只要引用还在,就不会被回收;
- 软引用(SoftReference):系统要发生内存溢出之前,会对其及逆行回收;
- 弱引用:每次GC发生时都会进行回收;
- 虚引用:无法使用,只是在对象被回收时发起一个系统通知。
2、垃圾回收算法
(1)标记清除算法(老年代回收算法)
算法分为两个阶段:
- 标记:标记出所有需要回收的对象;
- 清除:标记完成后统一回收所有被标记的对象。
缺陷:
- 效率低:标记和清除过程效率都不高;
- 清除后产生大量不连续的内存碎片,导致在之后的运行中需要分配较大对象时,无法找到足够的连续内存不得不提前进行下一次垃圾回收。
(2)复制算法(新生代算法)
复制算法解决了”标记-清理“的效率问题。将可用的内存按容量分为大小相等的两块,每次只使用一块。当内存需要进行垃圾回收时,会将该区域存活的对象复制到另一块上面,然后将已经使用过的内存区域清理掉。
使用场景:大多数对象具有朝生夕死的特性。新生代的对象符合该特性,使用复制算法。
缺陷:空间利用率不高,只有50%。
JVM中,新生代的回收算法,是复制算法的优化版本:
- 新生代中98%以上的对象都是“朝生夕死”的,所以不需要按照1:1来划分内存空间,而是将内存划分为一块较大的Eden区和两块较小的Survivor区(8:1:1);
- 每次使用Eden和一块Survivor。回收时,将Eden和Survivor中存活的对象一次性复制到另一块Survivor空间,最后清理掉Eden和使用的Survivor空间。
- 部分对象会在两个Survivor区域来回复制,如此交换15次(默认15)之后如果还存活,会vu你放到老年代。
(3)标记整理算法(老年代算法)
复制收集算法在对象存活率较高时会及逆行大量的赋值操作,效率比较低。因此来年代一般不使用。标记整理算法过程和标记清除算法一致,不同的是,标记完后,不直接进行清除,而是将存活的对象移动到一端,最后清除掉端边界以外的内存。
(4)分代算法
分代算法(没有具体的算法实现)是通过区域划分,实现不同的区域不同的垃圾回收策略,从而更好地实现垃圾回收。JVM垃圾收集都采用的是“分代收集”算法,只是根据对象的存活周期的不同将内存分为几块。一般是分为新生代和老年代。新生代中,每次垃圾回收只有少量的对象存活,使用复制算法;老年代中,对象存活率高,使用标记清除算法或标记整理算法。
哪些对象进入新生代?哪些对象进入老年代?
- 默认创建的对象(非大对象)都进入新生代;
- 老年代:
【1】大对象:对象占据的空间超出jvm规定的阈值;
【2】新生代中年龄超过15的对象。
【3】新生代GC时,分配担保失败的对象:新生代GC时把Eden区域和s0中的存活对象复制到s1区域中。(新生代对象的特点是朝生夕死,大多数情况下,存活的对象不足10%,但是也有可能s1区域中放不下,此时就放入老年代中。
什么时候发生垃圾回收?
对象进入哪个区域,如果该区域空间不足,就会触发该区域的GC。
Minor GC和Major GC有什么区别?
- 新生代GC/Minor GC:采用复制算法,效率比较高;
- 老年代GC/Major GC:采用标记清除算法/标记整理算法,效率比较差,一般比新生代GC慢十倍以上。
3、垃圾收集器
理解以下几个概念:
- 并行:指多条垃圾收集线程并行工作,用户线程仍处于等待状态(暂停SWT【Stop the World】);
- 并发:用户线程和垃圾收集线程同时执行(不一定并行,可能会交替执行),用户程序继续运行,二垃圾收集程序在另外一个CPU上;
- 吞吐量:CPU用于运行用户代码的时间和CPU总消耗时间的比值;(是一个程序的性能指标)
- 用户体验:stw单次停顿越长,用户体验就越差,此时用户体验就不好;单词stw停顿时间短一点,整个stw的时间可以长一点(用户体验更好,但吞吐量小(性能差一点))
适用场景:和用户打交道的程序,比如我们的一个Java网站后端。
jvm中经典的垃圾收集器:
- Serial收集器:新生代收集器(复制算法);单线程(单个垃圾回收线程)的方案=>目前大多数电脑都是支持多线程,所以这个收集器效率不高;
- ParNew收集器:新生代收集器;搭配CMS(老年代收集器)的方案;
- Parallel Scanvenge收集器:新生代收集器;吞吐量优先=>适应性能优先的程序;搭配Parallel Old(老年代收集器,也是吞吐量优先)
- Serial Old收集器:老年代收集器(标记整理算法);单线程;
- Parallel Old收集器:老年代收集器(标记整理算法);吞吐量优先。
(1)CMS收集器(Concurr Mark Sweep)
- 老年代收集器
- 标记清除算法
- 用户体验优先=>整体看是并发(垃圾回收线程和用户线程同时执行)的过程,有局部的stw(少许时间暂停用户线程)
- CMS搭配新生代的ParNew收集器
四个步骤:
- 初始标记:标记GC Roots能直接关联到的对象,需要SWT;
- 并发标记:进行GC Roots引用链追踪的过程(搜索引用路径);
- 重新标记:修复并发标记阶段用户线程同时执行产生标记变动的记录,需要SWT;(这个阶段停顿时间比初始标记阶段稍长,但远比并发标记时间短)
- 并发清除:并发清除垃圾。
优缺点:
【1】优点:并发收集、低停顿;
【2】缺点:
- CPU比较敏感:用户体验优先,意味着吞吐量低(单次停顿时间短,整体停顿时间长)=>CPU利用率下降;
- 无法处理浮动垃圾(浮动垃圾:并发清除时用户线程执行产生的垃圾):
(1)需要预留一部分空间:用来保存并发清除阶段用户线程创建的对象;
(2)并发模式失败(Concurrent MOdel Failure):并发清除阶段用户线程创建的对象超出预留的空间大小=>再次触发另一次老年代GC(老年代GC比较耗时),采用后备方案Serial Old方案进行回收- 内存碎片问题:标记清除算法会带来这样的问题,大对象时,内存足够,但连续内存不足,提前触发垃圾回收。
(2)G1收集器(唯一一个全区域的垃圾回收器)
其内存划分方式不再时新生代和老年代,而是把堆划分为多个大小相同的region区,动态分配为E区、S区、T区(老年代)以及H区(用于存储大对象,大小超过region大一的一半的对象)。
- 老年代收集器;
- 全堆收集器(most garbage优先回收):整体基于“标记整理算法”,局部基于“复制算法”;
- 用户体验优先。
回收方式:
(1)新生代垃圾收集:使用复制算法,把多个E区和S区存活的对象复制到空的region区在动态指定为S区)。
(2)老年代垃圾收集:分为四个阶段
- 初始阶段:和CMS类似(标记GC Roots关联的对象,SWT),不同的是,可以和新生代1GC同时执行;
- 并发标记:和CMS类似,同时如果发现那些Tenured(老年代)region中对象的存活率很小或者基本没有对象存活,会在次将其回收,不用等到最后阶段;
- 最终标记:和CMS的重新标记类似;
- 筛选回收:挑选对象存活率低的region进行回收,也是和新生代回收同时进行。
五、Java内存模型
Java内存模型(java Memory Model):用来屏蔽各种硬件和操作系统的内存访问差异,实现Java程序在各种平台下都能达到一致的内存访问效果。
1、主内存和工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量的底层细节。此处变量包括(实例字段、静态字段和构成数组对象的元素),但不包括局部变量和方法参数(是线程私有的,不会被线程共享)。
线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接对鞋主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的值的传递均需要通过主内存来完成的。
- 主存:存放线程共享的数据;
- 工作内存:存放线程私有的,及共享变量的拷贝。
2、内存间交互操作
(1)八大原子性的字节码指令
用于主存和工作内存数据的读取操作:
- lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占的状态;
- unlock(解锁):作用于主存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
- read(读取):作用于主存的变量,将变量从“主存”读取到“中间内存”;
- load(载入):作用于工作内存的变量,变量从“中间内存”读取到“工作内存”;
- use(使用):作用于工作内存的变量,使用变量参与运算;
- assign(赋值):作用于工作内存的变量,把值赋给变量;
- store(存储):作用于工作内存的变量,变量从“工作内存”写入“中间内存”;
- write(写入):作用于主内存的变量,变量从“中间内存”写入“主存”。
(2)Java内存模型的三大特性
- 原子性:Java内存模型的八大操作都是原子性的,即一个操作在执行过程中不会被任何因素打断;
- 可见性:指如果一个线程修改了共享变量的值,其他线程能够立即得知。(volatile、synchronized、final可以实现可见性)
- 有序性:在本线程内,观察自己线程,所有操作都是有序的;观察别的线程,所有的操作都是无序的。(线程内表现为串行,后半句指“指令重排序”和“工作内存与主内存同步延迟”现象)。