一、JVM
1.1 预备知识
虚拟机的分类
1. 系统虚拟机
对物理计算机的仿真。Eg:VMware、Oracle VM VirtualBox。
2. 程序虚拟机
执行单个计算机程序。Eg:JVM。
高级语言、汇编语言、机器语言的关系
Java 代码执行流程
首先使用 Java编译器 把 Java源代码 转换成 Java字节码。再通过 JVM 把 Java字节码 翻译成 机器指令。最后交给 CPU 去执行。
在 JVM 中,类加载器 把 Java字节码 加载到 运行时数据区 的方法区内。再使用执行引擎中的 解释器 or JIT编译器,将 Java字节码 翻译成 机器指令。以上过程需要使用 本地方法接口 和 本地方法库。最后 CPU 执行机器指令。
1.2 JVM
JVM 由 类加载器、运行时数据区、执行引擎、本地方法接口 组成。
类加载器
类加载器 把 Java字节码 加载到 运行时数据区 的方法区内。
运行数据区
运行数据区是 JVM 的内存。
运行时数据区 由 程序计数器、虚拟机栈、本地方法栈、堆、方法区组成。
执行引擎
执行引擎用于执行字节码文件中的指令。
本地方法接口
本地方法接口是 Java 调用其他语言的接口,与本地方法库交互。
1.3 对象的创建
对象创建的过程
-
类加载检查:检查这个指令的参数能否在常量池中找到一个类的符号引用,再检查这个类是否被类加载器加载过。如果没有,需要使用类加载器加载类。
-
为新生对象分配内存
-
将分配到的内存空间(但不包括对象头)都初始化为零值
-
设置对象头
-
调用对象的构造函数,即
<init>()
方法
类加载过程
内存分配方式
1. 指针碰撞
假设 JVM堆内存 是完整的,一个指针作为已用内存和可用内存的分界线。给对象分配内存,就是将这个指针向可用内存挪动一个对象的大小。常用此方案的垃圾回收器是 Serial、ParNew。
2. 空间列表
如果 JVM堆内存 不是完整的,JVM 会使用一个空闲列表,用来记录可用内存。给对象分配内存,就是从列表中找到足够大的可用内存分配给对象,并更新空闲列表。常用此方案的垃圾回收器是 CMS。
创建对象时,内存分配过程如何保证线程安全性?
-
对内存分配这个动作进行同步,采用 CAS ➕ 失败重试
-
本地线程分配缓冲:每个线程在 Java堆 中先预先分配一小块内存
1.4 对象的保存
Java对象保存在内存中时,由三部分组成:
-
对象头
-
实例数据
-
对齐填充
对象头
使用 JOL(Java Object Layout),Java对象布局 查看。
对象头由三部分组成:
-
Mark Word
-
指向类的指针
-
数组长度(只有数组对象才有)
1. Mark Word
Mark Word 保存了和锁有关的信息。
2. 指向类的指针
JVM 通过这个指针来确定该对象是哪个类的实例。
3. 数组长度
只有数组对象保存了这部分数据。
实例数据
对象的实例数据就是在 java代码 中能看到的属性和他们的值。
对齐填充
JVM 要求 Java对象 占的内存大小应该是 8字节的倍数,所以后面有几个字节用于把对象的大小补齐至8字节的倍数,没有特别的功能。
1.5 对象的访问定位
句柄访问
栈中的引用指向堆中的句柄,句柄包含两部分,一部分指向对象本身,另一部分指向对象类型信息。
直接指针访问
栈中的引用指向堆中的对象,堆中对象的对象头中的类型指针,指向方法区中的对象类型信息。
两者区别
句柄访问,对象被移动时,栈中的引用不用发生改变。
直接指针访问速度快。JVM 使用的是直接指针访问。
1.6 产生 OutOfMemoryError 的原因
-
JVM栈 或 本地方法栈 溢出
-
堆 溢出
-
方法区 溢出,多半是 jar包 过多。
-
直接内存 溢出
解决方法:调高 -Xss,-Xmx 与 -Xms。别的不会
1.7 产生 StackOverflowError 的原因
JVM栈 或 本地方法栈 溢出,一般是有死循环 或 递归调用 造成。
通过 -Xss 设置栈的大小。
二、类加载器
类加载器 把 Java字节码 加载到 运行时数据区 的方法区内。
第一次使用类是动态加载的,而不是一次性加载所有类。如果一次性加载,会占用很多内存。
类的生命周期
包括以下 7 个阶段:
-
加载(Loading)
-
验证(Verification)
-
准备(Preparation)
-
解析(Resolution)
-
初始化(Initialization)
-
使用(Using)
-
卸载(Unloading)
类加载过程
包含了加载、验证、准备、解析和初始化这 5 个阶段。
加载是类加载的一个阶段,注意不要混淆。
1. 加载
-
通过类的完全限定名获取该类的二进制字节流。
-
将该字节流的存储结构转换为方法区的运行时的存储结构。
-
在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。
其中二进制字节流可以从以下方式中获取:
-
从本地系统中直接加载
-
从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
-
从网络中获取,最典型的应用是 Applet。
-
运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
-
由其他文件生成,例如由 JSP 文件生成对应的 Class 类。
2. 验证
确保字节流中包含的信息符合 JVM 的要求,不会危害 JVM 自身的安全。
-
文件格式验证
-
元数据验证
-
字节码验证
-
符号引用验证
3. 准备
准备阶段为类变量分配内存并设置初始值。类变量是被 static 修饰的变量(就是静态变量)。
4. 解析
将常量池的符号引用替换为直接引用,也称为静态链接。
5. 初始化
开始执行类中定义的 Java 程序代码。
JVM判断两个类相同
两个类相等的条件:
-
类的全名相同
-
使用同一个类加载器进行加载
类加载器分类
1. 启动类加载器(Bootstrap ClassLoader)
加载 JVM 自身需要的类。
2. 扩展类加载器(Extension ClassLoader)
加载 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 指定路径下的所有类。
3. 应用程序类加载器(Application ClassLoader)
加载 java -classpath 或 -Djava.class.path 下的类。一般情况下这个就是程序中默认的类加载器。
4. 自定义加载器(User Define ClassLoader)
用户自己定义的类加载器。
双亲委派机制
1. 工作过程
一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。除了启动类加载器外,其它的类加载器都有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。
2. 优点
-
避免重复加载
-
避免核心类被篡改
3. 破坏双亲委派机制
可以⾃⼰定义⼀个类加载器,重写 loadClass() 方法;
类初始化的情况
-
遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,类没有初始化
-
对类进行反射调用时,类没有初始化
-
父类要在类初始化之前初始化
-
虚拟机启动时,先初始化包含 main 方法的主类
-
接口要在类初始化之前初始化
三、JVM运行时数据区
运行数据区是 JVM 的内存。
运行时数据区 由 程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区组成。
程序计数器、Java 虚拟机栈、本地方法栈 是 线程私有,堆、方法区 是 线程共享。
每一个 Java程序 都只有一个 Runtime实例,对应运行数据区。
JDK6 的 运行数据区:
JDK8 的 运行数据区:
3.1 程序计数器
作用:记录正在执行的 Java字节码指令的地址(如果正在执行的是本地方法则为空)。
为什么程序计数器是线程私有?
每一个线程都有一个程序计数器,用于记录当前线程的指令地址(也就是运行到哪一行了),方便多个线程来回切换并行执行。
3.2 JVM栈
作用:调用 Java方法。
方法:每一个线程对应一个程序计数器、JVM栈、本地方法栈。每执行一个 Java 方法,就在 JVM栈 里创建一个栈帧。从方法调用直到执行完成的过程,对应着一个栈帧在 JVM栈 中 入栈 和 出栈 的过程。
栈帧的作用:用于存储局部变量表、操作数栈、动态链接(常量池引用)、方法返回地址(方法正常退出or异常退出的定义)、一些附加信息。
-
局部变量表:定义为数字数组,用于存储方法参数和局部变量,这些数据包括8大基本数据类型、对象引用(reference)和 returnAddress类型。最基本的存储单元是 Slot(变量槽)。4字节及4字节以下的类型占用一个Slot,8字节的类型占用两个Slot。byte、short、char 在存储之前被转为 int;boolean 也转为 int,true 代表 1,false 代表 0 。需要访问局部变量表中的一个变量值,只需要使用索引即可。如果当前帧是构造器 或 new 一个对象,则该对象会放在 索引0 的 Slot 处,其余参数依次排序。
-
操作数栈:作为中间计算结果的临时存储空间。
-
动态链接(常量池引用):在 JVM 运行期间 将符号引用 转为 直接引用
-
方法返回地址:存放这个 Java方法的程序计数器的值。
-
一些附加信息:不做了解。
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M:
java -Xss2M HackTheJava
3.3 本地方法栈
作用:通过本地方法接口调用本地方法。
本地方法(native method)是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为适用于本机硬件和操作系统的程序。
3.4 堆
作用:几乎所有对象都在这里分配内存,是垃圾收集的主要区域。
现代的垃圾收集器基本都是采用分代收集算法,针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:
-
新生代(Young Generation):1/3 的堆内存
-
eden区:8/10的新生代
-
s0:1/10的新生代
-
s1:1/10的新生代
-
-
老年代(Old Generation):2/3 的堆内存
-
永久代:就是方法区,JDK 1.8 之后叫元空间,保存在本地内存空间。元空间存储类的元信息,堆存储静态变量和常量池等。
正常情况下,对象出生于Eden区(在Eden区分配内存),活过一次 GC,年龄增长到1岁,进入s0或s1。之后对象每活过一次GC,年龄就增加1岁,并在s0与s1之间互换。直到对象15岁了,在下一次GC之前,就进入永久代。
堆在物理内存上不需要连续,但在逻辑上应该被视为连续。可以动态增加堆内存,增加失败会抛出 OutOfMemoryError 异常。
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
java -Xms1M -Xmx2M HackTheJava
// -Xms: -X是JVM的运行参数 ms是memory start (size)
// -Xmx: -X是JVM的运行参数 mx是max (size)
栈和堆的区别
栈是运行时的单位,栈解决程序的运行问题;堆是存储的单位,堆解决数据存储问题。
-
栈是先进后出
-
栈存放的是局部变量,堆存放的是实体(实际的值)
-
栈存放的局部变量,生命周期一旦结束就会被释放;堆存放的实体会被垃圾回收机制不定时的回收
栈、堆、方法区的交互关系
3.5 方法区
作用:存放已被加载的常量、类变量、类的信息、其他信息等数据。
和堆一样不需要连续的内存。可以动态增加堆内存,增加失败会抛出 OutOfMemoryError 异常。
对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
方法区是一个 JVM 的规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间(位于本地内存)存储类的信息和其他信息,类变量和常量池等放入堆中。
运行时常量池
JDK 1.6 之前:
Java字节码 包含 常量池、类变量、类的信息、其他信息,Java字节码 通过类加载器加载到 JVM运行时数据区的方法区内。
运行时常量池保存:
1. 字面量
-
八大基本数据类型的常量值
-
字符串字面量:如 ”yifang“。保存的空间也成为 String Pool(字符串池)。
2. 符号引用
-
包
-
类和接口的全限定名
-
字段名和描述符
-
方法名和描述符
方法区的演进
方法区是一个 JVM 的概念,永久代与元空间都是其一种实现方式。
JDK 1.6
方法区靠永久代实现。类的信息、其他信息、字面量、符号引用、类变量 都保存在永久代中。
JDK 1.7
方法区靠永久代实现。类的信息和其他信息保存在永久代中。字面量、符号引用、类变量保存在堆中。
JDK 1.8
方法区靠元空间(保存在本地内存中)实现。类的信息和其他信息保存在元空间中。字面量、符号引用、类变量保存在堆中。
3.6 直接内存
作用:使用直接内存避免了在堆内内存和堆外内存之间来回拷贝数据。
直接内存在本地内存上,不在 JVM内存 上。
直接内存是用户态。
在 JDK 1.8 之后:
四、执行引擎
解释器
将 Java字节码 翻译成 机器指令。
JIT编译器
Just In Time 编译器,及时编译器。
JIT编译器 也将 Java字节码 翻译成 机器指令。JIT编译器 在 JVM 一开始启动的时候,执行速率比解释器更低,但在热点缓存之后,执行速率更高。热点缓存:只将有价值的字节码编程成机器指令。
五、垃圾回收(GC)
垃圾回收定义:JVM垃圾回收器 在空闲的时候,回收没有被对象引用的可回收的内存空间。
垃圾回收主要是针对堆和方法区进行,但更多是针对堆。程序计数器、JVM栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。
判断一个对象是否可被回收
1. 引用计数算法
原理:为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
缺点:两个对象出现循环引用的情况下,此时引用计数器永远不为 0,无法对它们进行回收。因为循环引用的存在,JVM 不推荐使用引用计数算法。
2. 可达性分析算法(GC Roots Tracing)
原理:以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
JVM 使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:
-
虚拟机栈中局部变量表中引用的对象
-
方法区中的常量引用的对象
-
同步锁持有的对象
其他回收
方法区的回收
定义:主要是对常量池的回收和对类的卸载。
缺点:因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
final、finally、finalize() 的区别
1. final
-
常量使用 final 关键字,一旦被赋值了,就不能再被更改了
-
final 声明方法不能被子类重写
-
final 声明类不允许被继承
2. finally
跟在 try-catch 后面,无论异常是否发生,都会执行 finally 里的代码。
3. finalize()
finalize() 在垃圾回收器回收对象之前调用,可以让对象重新被引用。但只能使用一次。
引用类型
1. 强引用
被强引用的对象不会被回收。
使用 new 一个新对象的方式来创建强引用。
Object obj = new Object();
2. 软引用
被软引用的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
3. 弱引用
被弱引用的对象一定会被回收,它只能存活到下一次垃圾回收之前。
使用 WeakReference 类来创建弱引用。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
4. 虚引用
虚引用不会对对象的生存时间造成影响,无法通过虚引用得到一个对象。
虚引用的目的:在这个对象被回收时收到一个系统通知。
使用 PhantomReference 来创建虚引用。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;
垃圾回收算法
1. 标记 - 清除
标记所有需要回收的对象,在标记完成之后,回收掉所有被标记的对象。
缺点:
-
标记和回收的效率不高
-
会产生许多不连续的内存碎片,导致无法给大对象分配内存
2. 标记 - 整理
把所有存活的对象都移动到一端,然后直接回收掉端边界以外的内存。
优点:不会产生内存碎片
缺点:需要移动许多对象,效率比较低。
3. 复制
将内存划分为大小相等的两块,每次只使用其中一块。当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次回收。
缺点:只使用了内存的一半。
现在的商业虚拟机都采用这种回收算法回收新生代,但是做了改进。将新生代划分为 Eden、Survivor 0、Survivor 1,比例为 8:1:1。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。这样保证内存的利用率达到 90%。
如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要借用老年代的空间存储放不下的对象。
分代垃圾回收算法
现在的商业虚拟机采用分代回收算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
-
新生代使用:复制算法
-
老年代使用:标记 - 清除 或者 标记 - 整理 算法
垃圾回收器
1. Serial 回收器
它是单线程的回收器。
它在进行垃圾回收时,必须暂停所有其他线程,直到它回收结束。这个过程称为 ”Stop The World“,简称 STW。
2. ParNew 回收器
它是 Serial 回收器的多线程版本。
3. Parallel Scavenge 回收器
它也是多线程回收器。
目的:控制吞吐量。
4. Serial Old 回收器
它是 Serial 回收器的老年代版本。
5. Parallel Old 回收器
是 Parallel Scavenge 回收器的老年代版本。
6. CMS 回收器
CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法,所以全称是 并发标记-清除算法。
目的:降低回收停顿时间(STW)。
分为以下四个流程:
-
初始标记:标记 GC Roots 能直接关联到的对象,需要停顿。
-
并发标记:执行 可达性分析算法,不需要停顿。
-
重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的标记,需要停顿。
-
并发清除:清除掉已经死亡的对象,不需要停顿。
优点:并发回收,低停顿 。
缺点:
-
吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
-
无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,所以需要预留出一部分内存。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。Concurrent Mode Failure 需要 Full GC 解决。
-
标记 - 清除算法导致的空间碎片过多。会出现老年代空间剩余,但无法找到足够大连续空间分配给当前对象,不得不提前触发一次 Full GC。
7. G1 回收器
其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
目的:提高吞吐量,降低回收停顿时间。
原理:G1 把堆划分成多个大小相等的独立区域(Region),每一个 Region 根据需要,扮演新生代的 Eden空间、Survivor空间,或者老年代空间。优先回收价值最大的 Region。
分为以下四个流程:
-
初始标记:标记 GC Roots 能直接关联到的对象,需要停顿。
-
并发标记:执行 可达性分析算法,不需要停顿。
-
最终标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的标记,需要停顿。
-
筛选回收:首先对各个 Region 中的 垃圾回收时间 和 回收所获得的空间 进行排序,再根据用户所指定的 GC 停顿时间来制定回收计划。
优点:
-
空间整合:基于 ”标记-整理“ 垃圾回收算法,运行期间不会产生内存空间碎片。
-
建立了可预测的停顿时间模型:能让使用者指定 消耗在 GC 上的时间不得超过 N 毫秒。
缺点:
-
G1 需要卡表记录新生代和老年代,这很占内存,并且需要额外的执行负载
JDK8默认垃圾回收器
Parallel Scavenge + Parallel Old
-
Parallel Scavenge 垃圾回收器管理的新生代
-
Parallel Old 垃圾回收器管理的老年代
六、内存分配与回收策略
Minor GC、Major GC、Full GC
-
Minor GC:回收整个新生代的垃圾
-
Major GC:回收整个老年代的垃圾。只有 CMS GC 有这种行为。
-
Mixed GC:回收整个新生代和部分老年代的垃圾。只有 G1 GC 有这种行为。
-
Full GC:回收整个堆和方法区的垃圾
内存分配策略
1. 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配。当 Eden 空间不够时,发起 Minor GC。Minor GC 会引发 STW。
2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
3. 长期存活的对象进入老年代
每个对象都有一个年龄计数器。对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄设置为 1 岁。对象每在 Survivor 中熬过一次 Minor GC ,年龄就增加一岁。增加到一定年龄(默认是15岁)则移动到老年代中。
4. 动态对象年龄判定
如果在 Survivor 中相同年龄的对象大小总数超过一半对象,则年龄大于等于该年龄的对象可以直接进入老年代。无需等到默认年龄再进入老年代。
5. 空间分配担保
在 JDK6 之后,只要老年代的最大连续可用空间大于新生代对象总空间或是历次晋升的平均大小,则会进行 Minor GC;否则进行 Full GC。
Full GC 的触发条件
1. 调用 System.gc()
不建议使用
2. 老年代空间不足
3. 空间分配担保失败
4. Concurrent Mode Failure
CMS 的浮动垃圾过多,会导致 Concurrent Mode Failure 错误,并触发 Full GC。
5. JDK 1.7 及以前的永久代空间不足
七、JVM 性能调优
关于JVM的Linux命令
常用命令:jps、jinfo、jstat、jstack、jmap
1. jps
查看 Java进程 及相关信息
2. jinfo
查看 JVM 参数
3. jstat
查看 JVM 运行时的状态信息
4. jstack
查看 JVM线程快照,jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环。
5. jmap
查看内存信息。
逃逸分析
开启逃逸分析之后,可将某些变量直接在栈上分配,而不是在堆上分配。这些变量可以被全局所引用,或者被其它线程所引用(栈上是线程共享的)。这个过程也称为 JVM逃逸(内存逃逸)。