一、java虚拟机概述
1. java程序的跨平台性
之前的话,通过Linux或者Windows开发,当需要跨平台时,程序不能运行。java出现后,产生了jvm,针对不同的操作系统,产生了不同的java虚拟机。
在Java虚拟机中执行的指令,称为Java字节码指令。
下面显示了同一个Java程序,被编译为一组Java字节码的集合之后,可以通过Java虚拟机运行于不同的操作系统上,它以Java虚拟机为中介,实现了跨平台的特性。
java文件先编译成class(字节码)文件,然后由对应操作系统的JVM进行解析, 不同系统的壁由JVM填平了,
2. JVM的基本结构
类加载子系统:java文件先被编译成class文件,类加载子系统(classLoader)加载class文件,在jvm层面,就会涉及类加载里的加载、验证、准备、解析、初始化五个步骤,实现类的加载行为。当class文件被加载完,就会进入JMM。
JMM:java 内存模型,包括:公有的方法区、java堆、私有的java栈、本地方法栈、PC寄存器
两个线程同时请求一个路径,请求的资源可以互通认为是公有的,反之为私有的。
栈对应的是方法,java栈是自己本地写的方法,本地方法栈是native方法,私有则意味着两个线程互不打扰,A线程运行到一个位置,此时B线程进来了,私有则意味着B要从头运行。
PC寄存器:记录着字节码运行到的位置。
class文件经过五个步骤加载完成后,类信息(常量池)保存到方法区中,对象调用之后才会用到JMM另外四个位置。
创建对象、数组都是放到堆里面。
垃圾回收系统:失去引用的对象为垃圾对象。包括:垃圾回收算法和垃圾回收器(※)。
执行引擎:负责虚拟机的字节码。(忽略)
3. JVM类加载流程和内存结构总览
4. 类加载——加载阶段
通过类的全路径名称,读取类的二进制数据流。解析类的二进制数据流,转化为方法区(永久代or元空间)内部的数据结构。创建java.lang.Class类的实例对象,表示该类型。
5. 类加载——验证阶段
它的目的是保证第一步中加载的字节码是合法且符合规范的。 大体分为4步验证:
- 格式检查:检查魔数、版本、长度等等。
- 语义检查:抽象方法是否有实现类、 是否继承了final类等等编码语义上的 错误检查。
- 字节码验证:跳转指令是否指向正确的位置,操作数类型是否合理等。
- 符号引用验证:符号引用的直接引用是否存在。
6. 类加载——准备阶段
准备阶段是正式为类变量分配内存并设置类 变量的初始值阶段,即:在方法区中分配这些变量所使用的内存空间。
注意这里所说的初始值概念,比如一个类变 量定义为:public static int v = 8080; 实际上变量v在准备阶段过后的初始值为0而不是 8080,将v赋值为8080的put static指令是程 序被编译后,存放于类构造器<clinit>方法之中。
但是注意,如果声明为:public static final int v = 8080; 在编译阶段会为v生成 ConstantValue属性,在准备阶段虚拟机会根 据ConstantValue属性将v赋值为8080。
7. 类加载——解析阶段
解析阶段是指虚拟机将运行时常量池中的符号引用替换为直接引用的过程。
符号引用就是class文件中的:CONSTANT_Class_info、CONSTANT_Field_info、 CONSTANT_Method_info 等类型的常量。
8. 类加载——初始化阶段
到达这个阶段,类就可以顺利加载到系统中。此时,类才会开始执行Java字节码。初始化阶段是执行类构造器<clinit>方法的过程。
在Terminal查询类信息:
javap -verbose 类名
<clinit>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子<clinit>方法执行之前,父类的<clinit>方法已经执行完毕,如果一 个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<clinit>() 方法。
内部静态类单例设计模式,不用担心多线程,因为内部静态类就是<clinit>的方法,该方法在初始化阶段就可以保证线程安全,就不用在方法上加synchronized或方法里用双重校验实现了线程安全了。
二、java虚拟机内存模型
1. 程序计数器
是当前线程所执行的字节码的行号指示器,指向虚拟机字节码指令的位置。
被分配了一块较小的内存空间。
针对于非Native方法(自己写的方法):是当前线程执行的字节码的行号指示器。
针对于Native方法:则为undefined。
每个线程都有自己独立的程序计数器,所以,该内存是线程私有的。
这块区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError情况的区域
2. 虚拟机栈 & 本地方法栈※
栈:先进后出;(备忘录模式用栈做的)
虚拟机栈为执行Java方法服务的,是描述方法执行的内存模型。
栈是线程私有的内存空间。
每次函数调用的数据都是通过栈传递的。
在栈中保存的主要内容为栈帧。它的数据结构就是先进后出。每当函数被调用,该函数就 会被入栈,每当函数执行完毕,就会执行出栈操作。而当前栈顶,即为正在执行的函数。
每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、帧数据区等信 息。
本地方法栈是为native方法服务的。
栈帧操作:
异常处理表:当try-catch出现异常时,从异常处理表中找到如何跳到catch异常的地方
局部变量和入参会影响栈帧(操作数栈)的大小。例如下面的例子:
例子1说明了栈内存大,则可存储的操作数多;
例子2说明了入参多,则局部变量表占用空间大,则可存储的操作数变少。
举例:栈内存有多大,栈深度有多大
/**
* 通过参数 -Xss来指定线程的最大栈空间
* 设置最大栈内存为 -Xss为160K,造成StackOverflowError异常后,查看输出的count值为:1907
* 设置最大栈内存为 -Xss为256K,造成StackOverflowError异常后,查看输出的count值为:2729
**/
public class StackOverflowTest {
private static int count = 0;
public static void main(String[] args) {
try {
count();
} catch (StackOverflowError e) {
System.err.println("StackOverflowError! count = " + count);
}
}
private static void count(){
count++;
count();
}
}
其中通过以下配置使用 VM 操作:
/**
* 增加局部变量表对栈空间占用的验证
* 设置最大栈内存为 -Xss256K,造成StackOverflowError异常
**/
public class StackOverflowTest2 {
private static int count = 0;
public static void main(String[] args) {
try{
// count1(1,2); // count = 19111
count2(1,2,3,4,5); // count = 11221
}catch (Throwable e){
System.err.println(count);
}
}
private static void count1(int a, int b){
count++;
int num1 = 1, num2 = 2;
count1(a + num1,b + num2);
}
private static void count2(int a, int b, int c, int d, int e){
count++;
int num1 = 1, num2 = 2, num3 = 3, num4 = 4, num5 = 5, num6 = 6, num7 = 7, num8 = 8;
count2(a + num1 + num2,b + num3, c + num4, d + num5 + num6, e + num7 + num8);
}
}
3. 堆
运行时数据区,几乎所有的对象都保存在java堆中。
Java堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会被自动清理,而不需要显示地释放。
堆是垃圾收集器进行GC的最重要的内存区域。
Java堆可以分为:新生代(Eden区、S0区、S1区)和 老年代。
在绝大多数情况下,对象首先分配在eden区,在一次新生代GC回收后,如果对象还存活, 则会进入S0或S1,之后,每经历过一次新生代回收,对象如果存活,它的年龄就会加一。 当对象的年龄达到一定条件后,就会被认为是老年代对象,从而进入老年代。
4. 方法区
逻辑上的东西,是JVM的规范,所有虚拟机必须遵守的。(类似接口)
是JVM 所有线程共享的、用于存储类信息,例如:类的字段、方法数据、常量池等。
方法区的大小决定了系统可以保存多少个类。
JDK8之前——永久代。
JDK8及之后——元空间。
永久代
指内存的永久保存区域,主要存放Class和Meta(元数据)的信息,Class在被加载的时候被放入永久区域,它和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常(OutOfMemory)。
如果系统使用了一些动态代理(会调用一系列的Filter、Adapter、Handler),那么有可能会在运行时生成大量的类,从而造成内存溢出。 所以,设置合适的永久代大小,对于系统的稳定性是至关重要的。
-XX:PermSize 设置初始永久代大小。例如:-XX:PermSize=5m
-XX:MaxPermSize 设置最大永久代大小,默认情况下为64MB。例如:-XX:MaxPermSize=5m
元空间
在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。
元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用堆外的直接内存。
因此,与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。
-XX:MaxMetaspaceSize 设置元空间默认初始大小。
例如:-XX:MetaspaceSize=40m 设置最大元数据空间。例如:-XX:MaxMetaspaceSize=40m
常见面试题:为什么使用元空间替换永久代?※
因为永久代在过去的实现中存在一些问题和限制,而元空间提供了更好的性能和灵活性。以下是一些详细的原因:
(1)内存管理:永久代的内存管理是由虚拟机自身控制的,无法根据应用程序的需求进行动态调整。而元空间使用本地内存进行管理(堆外直接内存),可以根据应用程序的需求动态分配和释放内存,提高内存的利用率。
(2)永久代内存溢出:在永久代中,存储类的元数据、常量池、静态变量等,当应用程序加载大量类或者使用大量字符串常量时,可能导致永久代内存溢出。而元空间不再有固定的大小限制,可以根据应用程序的需要自动扩展。
(3)类的卸载:在永久代中,由于类的卸载机制比较复杂,很难实现完全的类卸载。而元空间使用本地内存,可以更容易地实现类的卸载,减少内存的占用。
(4)性能优化:元空间的实现采用了更高效的数据结构和算法,例如使用指针碰撞(Bump the Pointer)的方式分配内存,减少内存碎片化,提高内存分配的效率。此外,元空间还支持并发的类加载和卸载操作,提高了性能。(5)垃圾收集的复杂性:永久代的垃圾收集比较复杂,因为它涉及到类的卸载,而类的卸载又和类加载器有关。在某些情况下,即使类不再被使用,但由于类加载器的存在,类也不会被卸载,从而导致内存泄漏。此外,永久代的垃圾收集通常与Java堆的其他部分分开进行,增加了垃圾收集器的实现复杂性。
————————————————
原文链接:https://blog.youkuaiyun.com/weixin_44989660/article/details/137261106
三、垃圾回收算法※
1. 什么是垃圾回收
GC:垃圾回收,即:Garbage Collection。
垃圾:特指存在于内存中的、不会再被使用的对象,或者跳出方法外,也是垃圾
回收:清除内存中的“垃圾”对象。
2. 可触及性
什么是可触及性? 就是GC时,是根据它来确定对象是否可被回收的。 也就是说,从根节点开始是否可以访问到某个对象,也说明这个对象是否被使用。
可触及性分为3种状态:
① 可触及:从根节点开始,可以到达某个对象(存在调用关系的时候)。
② 可复活:对象引用被释放(被置为null),但是可能在finalize()函数中被初始化复活。
③ 不可触及:由于finalize()只会执行一次,所以,错过这一次复活机会的对象,则为不可触及状态
示例:
/**
* 死去活来的对象
*
* @author : wanglinping
* @version : 1.0
* @time : 2024/9/16 11:17
**/
public class DieAliveObject {
private static DieAliveObject dieAliveObject;
public static void main(String[] args) {
dieAliveObject = new DieAliveObject();
int i = 0;
while (i < 2){
System.out.println(String.format("--------GC nums = %d--------", i++));
dieAliveObject = null; // 将dieAliveObject对象置为“垃圾对象”
System.gc(); // 通知JVM可以执行GC了
try{
Thread.sleep(100); // 等待GC执行
}catch (InterruptedException e){
e.printStackTrace();
}
if (dieAliveObject == null){
System.out.println("dieAliveObject is null");
} else {
System.out.println("dieAliveObject is not null");
}
}
}
/*
* finalize只会被调用一次,给对象唯一一次重生的机会
* */
@Override
protected void finalize() {
System.out.println("finalize is called!");
dieAliveObject = this; // 使对象复生,添加引用
}
}
运行结果:
dieAliveObject 对象被置为空之后进行判断,第一次判断该对象不为空是因为调用了finalize方法,对象被复活,但因为只能复活一次,所以第二次该对象被判断为空。
四种引用级别※
引用类型 | 说明 |
强引用 | 就是一般程序中创建的引用,例如 Student student = new Student(); |
软引用 SoftReferenct | 当堆空间不足时,才会被回收。因此,软引用对象不会引起内存溢出。 通过 .get() 方法 引用,当发生了gc,如果空间不足才会返回null,空间足够的话依旧可以通过get获得student对象。 |
弱引用 WeakReferenct | 当GC的时候,只要发现存在弱引用,无论系统堆空间是否不足,均会将其回收。 通过 .get() 方法引用,gc之前会获得对象,当发生了gc,返回null。(ThreadLocal会使用弱引用,ThreadLocal里的key为什么是弱引用※) |
虚引用 PhantomReferenct | 如果对象持有虚引用,其实与没有引用是一样的。虚引用必须和引用队 列在一起使用,它的作用是用于跟踪GC回收过程,所以可以将一些资 源释放操作放置在虚引用中执行和记录 通过 .get() 方法引用,无论什么情况,都返回null。(使用非常少) |
示例:
软引用:
/**
* 软引用示例
* -Xmx10m -XX:+PrintGCDetails
**/
public class SoftReferenceDemo {
public static void main(String[] args) throws Throwable{
/* 查看空余内存 */
System.out.println("----Free " + Runtime.getRuntime().freeMemory() / 1000000 + "M-----");
/* 创建Teacher对象的软引用 */
Teacher teacher = new Teacher("aa", 15);
SoftReference<Teacher> softReference = new SoftReference<>(teacher);
System.out.println("softReference = " + softReference.get());
/* 使得teacher失去引用,可被GC回收 */
teacher = null;
/* 执行第一次GC后,软引用并未被回收 */
System.gc();
System.out.println("------First GC------");
System.out.println("softReference = " + softReference.get());
/* 可以通过对数组大小数值调整,来造成内存资源紧张 */
byte[] bytes = new byte[7 * 937 * 1024];
System.out.println("------Assign Big Object------");
/* 执行第二次GC,由于堆空间不足,所以软引用已经被回收 */
System.gc();
System.out.println("------Second GC------");
Thread.sleep(1000);
System.out.println("softReference = " + softReference.get());
}
}
class Teacher{
private String name;
private int age;
public Teacher(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Teacher{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
运行结果:
可以看到当内存不足时,gc() 之后返回null。
弱引用:
/**
* 弱引用demo
**/
public class WeakReferenceDemo {
public static void main(String[] args) throws Throwable{
/* 创建Teacher对象的弱引用 */
Teacher teacher = new Teacher("aaa", 20); // teacher的强引用
WeakReference<Teacher> weakReference = new WeakReference<>(teacher); // teacher的弱引用
/* 使得teacher失去引用来了,可被GC回收 */
teacher = null;
/* 执行GC前,查看弱引用并未被回收 */
System.out.println("------Before GC------");
System.out.println("weakReference = " + weakReference.get());
/* 执行GC,所以弱引用已经被回收 */
System.gc();
System.out.println("------After GC------");
Thread.sleep(1000); // 睡眠1秒钟,保证GC已经执行完毕
System.out.println("weakReference = " + weakReference.get());
}
}
运行结果:
虚引用:
/**
* 虚引用demo 虚引用必须和引用队列一起使用
**/
public class PhantomReferenceDemo {
private static PhantomReferenceDemo obj;
public static void main(String[] args) {
/* 创建引用队列 */
ReferenceQueue<PhantomReferenceDemo> phantomReQueue = new ReferenceQueue<>();
/* 创建虚引用 */
obj = new PhantomReferenceDemo();
PhantomReference<PhantomReferenceDemo> phantomReference = new PhantomReference<>(obj, phantomReQueue);
System.out.println("phantomReference: " + phantomReference.get()); // 总会返回null
/* 创建后台线程 */
CheckRefQueueThread thread = new CheckRefQueueThread(phantomReQueue);
thread.setDaemon(true);
thread.start();
/* 执行两次GC,一次被finalize复活,一次真正被回收 */
for (int i = 1; i <= 2; i++) {
gc(i);
}
}
public String print(){
return "这是一个打印方法";
}
private static void gc(int nums){
obj = null;
System.gc();
System.out.println("------第" + nums + "次GC------");
try{
Thread.sleep(500);
}catch(InterruptedException e){
e.printStackTrace();
}
if (obj == null){
System.out.println("obj is null");
} else {
System.out.println("obj is not null");
}
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize() is called!");
obj = this; // 复活对象
}
}
/*
* 从引用队列中获得被回收的对象
* */
class CheckRefQueueThread extends Thread{
private ReferenceQueue<PhantomReferenceDemo> phantomRefQueue;
public CheckRefQueueThread(ReferenceQueue<PhantomReferenceDemo> phantomRefQueue) {
this.phantomRefQueue = phantomRefQueue;
}
@Override
public void run() {
while (true){
if (phantomRefQueue != null){
PhantomReference<PhantomReferenceDemo> phantomReference = null;
try {
/* 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,
将这个虚引用加入到引用队列,以通知应用程序对象的回收情况 */
phantomReference = (PhantomReference<PhantomReferenceDemo>) phantomRefQueue.remove();
} catch (Throwable e){
e.printStackTrace();
}
if (phantomReference != null){
System.out.println("Object = " + phantomReference + "is delete by GC");
}
}
}
}
}
运行结果:
3. 槽位复用
局部变量表中的槽位是可以复用的,如果一个局部变量超过了其作用域,则在其作用域之后 的局部变量就有可能复用该变量的槽位,这样能够起到节省资源的目的。
4. 对象分配总览※
栈上分配的两种技术(逃逸分析和标量替换)和TLAB分配都是默认开启的。
默认值是0默认为新生代分配,执行15次新生代分配后变为老年代分配。
栈上分配 和 TLAB分配都是在栈或者线程上进行的(不需要gc回收,但因为空间小,所以在整个对象回收的占比来说比较小);老年代分配和新生代分配是在堆上进行的。
新生代分配特点:朝生暮死,执行一次gc的时间短。执行频繁,每一次执行速度快。
老年代:执行不频繁,每一次执行非常耗时。
对象分配 — — 栈上分配
栈上分配是JVM提供的一项优化技术。基本思想如下所示:
- ① 对于那些线程私有的对象(即:不可能被其他线程访问的对象),可以将它们打散分 配在栈上,而不是分配在堆上。
- ② 分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入, 从而提高系统的性能。
- ③ 对于大量的零散小对象,栈上分配提供了一种很好的对象分配优化策略,栈上分配速度快,并且可以有效避免GC带来的负面影响,但是由于和堆空间相比,栈空间较小, 因此对于大对象无法也不适合在栈上分配。
栈上分配的技术基础,两者必须都开启:
- ① 逃逸分析:逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。
- ② 标量替换:允许将对象打散分配在栈上。比如:若一个对象拥有两个字段,会将这两 个字段视作局部变量进行分配。
栈上分配 — — 逃逸分析
对于线程私有的对象,可以分配在栈上,而不是分配在堆上。好处是方法执行完,对象自行销毁,不需要gc介入。可以提高性能。而栈上分配的一个技术基础(如果关闭逃逸分析或关 闭标量替换,那么无法将对象分配在栈上)就是逃逸分析。
逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。如图所示:
栈上分配 — — 标量替换
标量:不可被进一步分解的量,JAVA的基本数据类型就是标量(如:int,long等基本数据类型等)。
聚合量:标量的对立就是可以被进一步分解的量,JAVA中对象就是可以被进一步分解的聚合量。
替换过程:
- ① 通过逃逸分析确定该对象不会被外部访问。
- ② 对象可以被进一步分解,即:聚合量。其中,JVM不会创建该对象,而会将该对象成 员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或 寄存器上分配空间。
举例:
/**
* 栈上分配(以下三种默认都是开启的)
* 逃逸分析:DoEscapeAnalysis
* 标量替换:EliminateAllocations
* TLAB:UseTLAB
* 【栈上分配】-Xmx50m -Xms50m -XX:+PrintFlagsFinal -XX:+PrintGCDetails -XX:-UseTLAB
* (设置堆的初始化大小,以及最大大小为50M,打印出当前配置参数的状态,打印回收的情况,不使用TLAB,冒号后减号是关闭,加号是开启)
* 【关闭栈上分配】-Xmx50m -Xms50m -XX:+PrintFlagsFinal -XX:+PrintGCDetails -XX:-UseTLAB -XX:-DoEscapeAnalysis
* 注意:-XX:+PrintFlagsFinal只是为了查看参数设置情况,可以去掉。
**/
public class AssignOnStack {
public static void main(String[] args) {
sizeOfStudent();
StopWatch stopWatch = StopWatch.createStarted();
// 制造将近7.5个G左右的对象
for (int i=0; i< 100000000; i++) {
initStudent();
}
stopWatch.stop();
System.out.println("========执行一共耗时:" + stopWatch.getTime(TimeUnit.MILLISECONDS) + "毫秒");
}
/**
* student所占用空间为72bytes
*/
public static void sizeOfStudent() {
Student student = new Student();
student.setName("wahaha");
System.out.println("========student大小为:" + ObjectSizeCalculator.getObjectSize(student));
System.out.println("========student大小为:" + RamUsageEstimator.humanSizeOf(student));
}
public static void initStudent() {
Student student = new Student();
student.setName("wahaha");
}
}
运行结果:
栈上分配:4ms (在栈上分配了,无需gc回收操作)
关闭栈上分配:1.5s
结论:直接采用堆上分配,效率很低。
对象分配 — — TLAB分配
TLAB的全称是Thread Local Allocation Buffer,即:线程本地分配缓存区,这是一个线程 专用的内存分配区域。
由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆 上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS配上失败重试的方式 保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB 来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程 同步,提高了对象分配的效率。
TLAB本身占用Eden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB 空间。参数-XX:+UseTLAB开启TLAB,默认是开启的。TLAB空间的内存非常小,缺省情况下仅占整个Eden空间的1%(80%的1%),当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小(参数一般不建议设置)。由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行 分配,总是会直接分配在堆上。TLAB空间由于比较小,因此很容易装满。
举例:把逃逸分析关掉了: -XX:-DoEscapeAnalysis
/**
* -XX:+UseTLAB -XX:+PrintTLAB -Xcomp -XX:-BackgroundCompilation -XX:-DoEscapeAnalysis
* -XX:-UseTLAB -XX:+PrintTLAB -Xcomp -XX:-BackgroundCompilation -XX:-DoEscapeAnalysis
* -XX:-BackgroundCompilation : 不执行后台编译
**/
public class TLabDemo {
public static void alloc() {
byte[] b = new byte[2];
b[0] = 1;
}
public static void main(String[] args) {
long b = System.currentTimeMillis();
for (int i=0; i<10000000;i++) {
alloc();
}
long e = System.currentTimeMillis();
System.out.println("------------TLabDemo耗时:" + (e-b) + "------------");
}
}
运行结果:
开启了TLAB:会输出TLAB日志,用时98ms;
关闭了TLAB:没有了TLAB的日志,用时189ms。
5. 主要的垃圾回收算法
引用计数法:增加引用+1,失去引用-1 (最原始的方法)
标记清除法:但内存碎片多,对于大对象的内存分配。不连续的内存空间分配效率低于连续空间。是现代 垃圾回收算法的思想基础。
复制算法:为了解决标记清除算法效率低的问题。该算法效率高,并且没有内存碎片,但是只能使用一 半的系统内存。适用于新生代。
标记压缩法:为了解决复制算法只能使用1/2内存的问题。 适用于垃圾对象多的情况,适用于老年代。
分代算法(策略):将内存区间根据对象的生命周期分为两块,每块特点不同,使用回收算法也不同,从而提升 回收效率。(分为新生代和老年代)
分区算法(策略):将这个堆空间划分成连续不同的小区间,每个区间独立使用、独立回收。避免GC时间过 长,造成系统停顿。
引用计数法 (Reference Counting)
(java里没有用到,不太会面试到)
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时, 引用计数器就减1;只要对象A的引用计数器的值为0,则对象A就不可能再被使用。
但引用计数器有两个严重问题: ① 无法处理循环引用的情况。 ② 引用计数器要求在每次因引用产生和消除的时候,需要伴随一个加减法操作,对系统性能会有一定的影响。 因此:JVM并未选择此算法作为垃圾回收算法。
标记清除法( Mark-Sweep)
标记清除算法是现代垃圾回收算法的思想基础。分为两个阶段:标记阶段和清除阶段。标记清除算法产生最大的问题就是清除之后的空间碎片。
黑色的是垃圾,白色的是未占用的(垃圾被清理之后的)
复制算法
将原有内存空间分为两块。每次只使用其中一块内存,例如:A内存,GC时将存活的对象 复制到B内存中。然后清除掉A内存所有对象。开始使用B内存。复制算法没有内存碎片,并 且如果垃圾对象很多,那么这种算法效率很高。但是它的缺点是系统内存只能使用1/2。
复制算法在JVM中的使用
因为新生代大多对象都是“朝不保夕”,所以在新生代串行GC中,使用了复制算法。
① 设置Eden区与Survivior区比例的jvm参数:-XX:SurvivorRatio
② 设置BigObject的jvm参数:-XX:PretenureSizeThreshold
③ 设置OldObject的jvm参数:-XX:MaxTenuringThreshold
“不是直接跳过Eden区,而放到老年代”
标记压缩法(Mark-Compact)
标记压缩算法是一种老年代的回收算法。
垃圾回收步骤:
- ① 标记存活的对象
- ② 将所有存活的对象压缩到内存的一端
- ③ 清理所有存活对象之外的空间。
该算法不会产生内存碎片,并且也不用将内存 一分为二。因此,在老年代中其性价比较高。
分代算法
将堆空间划分为新生代和老年代,根据它们之间的不同特点,执行不同的回收算法,提升回 收效率。当前jvm的垃圾回收,都是采用分代收集算法的,针对新生代和老年代,他们对应 的垃圾回收算法如下所示:
① 【新生代】由于大量对象消亡,少数存量对象,只需要复制少量对象,就可以完全清除S0/S1的垃圾对象空间,所以采用“复制算法”更为合适;
② 【老年代】对象存活率高,每次GC只清除少部分对象,所以采用“标记-清除”或“标记- 压缩”算法来回收。
分区算法
将堆空间划分成连续的不同小区间,每个区间独立使用、回收。由于当堆空间大时,一 次GC的时间会非常耗时,那么可以控制每次回收多少个小区间,而不是整个堆空间,从 而减少一次GC所产生的停顿。
四、JVM垃圾收集器※
(哪些垃圾回收器用哪些垃圾回收算法※)
1. 串行回收器总览
2. 串行回收器 - Serial
串行回收器也叫Serial收集器,是最古老收集器。它在JDK1.3之前是虚拟机新生代收集器的唯一选择。
它是单线程执行回收操作的。它的特点就是,在单核或内核少的计算机来说,有更好的性能表现。它的优点就是简单高效。
3. 串行回收器 - 配置JVM参数启动指定的垃圾
使用-XX:+UseSerialGC可以指定新生代和老 年代都是Serial收集器。
使用-XX:+PrintCommandLineFlags可以打印 虚拟机参数
新生代串行回收器采用:复制算法
老年代串行回收器采用:标记压缩算法
4. 并行回收器总览
将串行回收器并行化,与串行回收器有相同的回收策略、算法、参数。
5. 并行回收器 - ParNew回收器
ParNew是一个新生代的回收器,也是一个独占式的回收器,它与串行回收器唯一不同 的,就是它采取并发方式执行GC。大家一定要注意一点,就是在cpu核数少的机器,它 的性能很可能比串行回收器差。
-XX:ParallelGCThreads指定并行GC的线程个数,最好与CPU个数一致,否则会影响垃圾回收性能。
默认情况下,当CPU数量<=8个的时候,并行线程数为8个。如果CPU数量>8个,并行线程数量为 3+(5*cpu_nums/8)
查看并行线程数:jinfo -flag ParallelGCThreads 34799
6. 并行回收器 - ParallelGC回收器
ParallelGC也是新生代的回收器,也采用的复制算法执行GC回收任务。它与ParNew有一个不同点就是,它提供了一些设置系统吞吐量的参数用来控制GC行为。
-XX:MaxGCPauseMillis:最大的垃圾收集暂停时间,它是一个大于0的整数,ParallelGC会根据设置的值来调整堆的大小和其他jvm参数,使其把GC停顿时间控制在 MaxGCPauseMillis之内,但是大家要注意,如果将值设置很小,虽然停顿时间小了,却造 成初始化的堆也变小了,垃圾回收会变得很频繁。
-XX:GCTimeRatio:设置吞吐量大小,可设置的值为0~100之间的整数。什么意思呢?就是 说它影响的是垃圾回收时间,通过1/(1+n)来计算,假设n=99,那么1/(1+99)=1%,也就是说, 系统会花费小于1%的时间用于垃圾回收。
-XX:+UseAdaptiveSizePolicy:如果你不倾向手动设置上面的参数,可以采用把参数调整交 由虚拟机自动设置。
7. 并行回收器 - ParallelOldGC回收器
它跟ParallelGC相似,也是关注于吞吐量的收集器。从名字看,它比ParallelGC多了个Old, 其实就表示它是一个应用于老年代的回收器。
可以与ParallelGC搭配使用,即:ParallelGC(新生代收集器)+ ParallelOldGC(老年代收集器)。
它采用标记压缩算法进行GC操作。也可以使用-XX:ParallelGCThreads来指定并行GC的线程个数。指令:jinfo -flag ParallelGCThreads 1111
查询是否使用了该回收器:jinfo -flag UseParallelOldGC 1111
8. 并行回收器 - 配置JVM参数启动指定的垃圾收集器
启用指定收集器:
9. 并行回收器 - CMS※
CMS全称为Concurrent Mark Sweep,即:并发标记清除。 它采用的是标记清除算法。也是多线程并发执行器。分为如下6个步骤:
- ① 初始标记(STW):标记根对象
- ② 并发标记:标记所有对象
- ③ 预清理:清理前的准备以及控制停顿时间(可以采用-XX:-CMSPrecleaningEnabled 关闭,不进行预清理)
- ④ 重新标记(STW):修正并发标记数据
- ⑤ 并发清理:清理垃圾(真正的执行垃圾回收)
- ⑥ 并发重置:重置状态等待下次CMS的触发
为什么要有预清理?
因为第4步重新标记是独占CPU的,如果YoungGC发生后,立即触发一次重新标记(新 生代使用ParNew回收器),那么一次停顿时间可能很长,为了避免这种情况,预处理时, 会刻意等待一次新生代GC的发生,然后根据历史数据预测下一次YoungGC的时间,在当前 时间和预测时间取中间时刻执行重新标记操作,目的就是尽量避免YoungGC与重新标记重叠 执行,从而减少一次停顿时间。
根据上面介绍的CMS垃圾回收器的6个回收步骤,以图示方式展现如下:
10. 并行回收器jvm参数总览
11. G1的垃圾收集概述※※(步骤)
G1全称Garbage First Garbage Collector。优先回收垃圾比例最高的区域。
G1收集器将堆划分为多个区域,每次收集部分区域来减少GC产生的停顿时间。一二三阶段循环执行。
- 第一阶段:新生代GC
- 第二阶段:并发标记周期
- 第三阶段:混合收集
- 第四阶段:Full GC(不是必须)
G1第一阶段:新生代GC
新生代GC的主要工作就是回收eden区和survivor区。一旦eden区被占满,新生代GC就会启动。回收后,所有的eden区都应该被清空,而survivor区会被收集一部分数据,但是应该至少仍然存在一个survivor区。如下图所示:
G1第二阶段:并发标记周期※
初始标记(STW):标记从根节点直接可到达的对象。这阶段会伴随一次Young GC, 会产生STW(全局停顿),应用程序会停止执行。
根区域扫描:由于Young GC的发生,所以初始标记后,eden被清空,存活对象放入 Survivor区。然后本阶段,则扫描survivor区,标记可直达老年代的对象。本阶段应用 程序可以并行执行。但是,根区域扫描不能和YoungGC同时执行(因为根区域扫描依赖 survivor区的对象,而新生代GC会修改这个区域),因此如果恰巧在此时需要进行 YoungGC,GC就需要等待根区域扫描结束后才能进行,如果发生这种情况,这次 YoungGC的时间就会延长。
并发标记:用来再次扫描整个堆的存活对象,并做好标记。与CMS类似,该阶段可以被 一次Young GC打断。
重新标记(STW):本阶段也会发生STW,应用程序会停止执行。由于并发标记阶段中, 应用程序也是并发执行的,所以本阶段,对标记结果进行最后的修正处理。
独占清理(STW):本阶段也会发生STW,应用程序会停止执行。它用来计算各个区域 的存活对象和GC回收比例,然后进行排序,从而识别出可以用来混合收集的区域。该阶段给出了需要被混合回收的区域并进行了标记,那么在混合收集阶段,是需要这些信息的。
并发清理:本阶段会去识别并清理那些完全空闲的区域。
G1第三阶段:混合收集
在第二步的并发标记周期过程中,虽然有部分对象被回收,但是总体回收比例还是比较 低的。由于G1已经明确知道哪些区域含有比较多的垃圾比例,所以就可以针对比例较 高的区域进行回收操作。
五、JVM常用参数
执行语法: Java [-options] [package+className] [arg1,arg2,…,argN]
options:
-Xms128m 设置初始化堆内存为128M
-Xmx512m 设置最大堆内存为512M
-Xmn160m 设置新生代大小为-Xmn160M(堆空间1/4~1/3)
-Xss128m 设置最大栈内存为128M
-XX:SurvivorRatio 设置新生代eden区与from/to空间的比例关系
-XX:PermSize=64M 设置初始永久区64M
-XX:MaxPermSize=128M 设置最大永久区128M
-XX:MaxMetaspaceSize 设置元数据区大小(JDK1.8 取代永久区)
-XX:+DoEscapeAnalysis 启用逃逸分析(Server模式)
-XX:+EliminateAllocations 开启标量替换(默认开启)
-XX:+TraceClassLoading 跟踪类的加载
-XX:+TraceClassUnloading 跟踪类的卸载
-Xloggc:gc.log 将gc日志信息打印到gc.log文件中
-XX:+PrintGC 打印GC日志
-XX:+PrintGCDetails 打印GC详细日志
-XX:+PrintGCTimeStamps 输出GC发生的时间
-XX:+PrintGCApplicationStoppedTime GC产生停顿的时间
-XX:+PrintGCApplicationConcurrentTime 应用执行的时间
-XX:+PrintHeapAtGC 在GC发生前后,打印堆栈日志
-XX:+PrintReferenceGC 打印对象引用信息
-XX:+PrintVMOptions 打印虚拟机参数
-XX:+PrintCommandLineFlags 打印虚拟机显式和隐式参数
-XX:+PrintFlagsFinal 打印所有系统参数
-XX:+PrintTLAB 打印TLAB相关分配信息
-XX:+UseTLAB 打开TLAB
-XX:TLABSize 设置TLAB大小
-XX:+ResizeTLAB 自动调整TLAB大小
-XX:+DisableExplicitGC 禁用显示GC (System.gc())
-XX:+ExplicitGCInvokesConcurrent 使用并发方式处理显式GC
六、 JVM监控优化
性能监控工具:
Linux:top、vmstat、iostat、pidstat
JDK:jps、jstat、jinfo、jmap、hprof、jhat、jstack、jstatd、jcmd
工具:JConsole、Visual VM、MissionControl
1. Linux - top命令
能够实时显示系统中各个进程的资源占用情况。 分为两部分:系统统计信息&进程信息。
系统统计信息:
Line1: 任务队列信息,从左到右依次表示:系统当前时间、系统运行时间、当前登录用户数。
Load average表示系统的平均负载,即任务队 列的平均长度——1分钟、5分钟、15分钟到现在的平均值。
Line2: 进程统计信息,分别是:正在运行进程数、 睡眠进程数、停止的进程数、僵尸进程数。
Line3: CPU统计信息。us表示用户空间CPU占用率、sy表示内核空间CPU占用率、ni表示用户进程空 间改变过优先级的进程CPU占用率。id表示空闲CPU占用率、wa表示待输入输出的CPU时间百分比、 hi表示硬件中断请求、si表示软件中断请求。
Line4: 内存统计信息。从左到右依次表示:物理内存总量、已使用的物理内存、空闲物理内存、内 核缓冲使用量。
Line5:从左到右表示:交换区总量、已使用交换区大小、空闲交换区大小、缓冲交换区大小。
进程信息:
PID:进程id
USER:进程所有者
PR:优先级
NI:nice值,负值高优先级,正值低优先级
VIRT:进程使用虚拟内存总量 VIRT=SWAP+RES
RES:进程使用并未被换出的内存。CODE+DATA
SHR:共享内存大小
S:进程状态。 D=不可中断的睡眠状态 R=运行 S=睡眠 T=跟踪/停止 Z=僵尸进程
%CPU:上次更新到现在的CPU时间占用百分比
%MEM:进程使用的物理内存百分比
TIME+:进程使用的CPU时间总计,单位 1/100秒
COMMAND:命令行
2. Linux - vmstat命令
性能监测工具,显示单位均为kb。它可以统计CPU、内存使用情况、swap使用情况等信息,也可 以指定采样周期和采用次数。例如:每秒采样一次,共计3次。
vmstat 1 3
procs列:r表示等待运行的进程数。b表示处于非中断睡眠状态的进程数。
memory列:swpd表示虚拟内存使用情况。free表示空闲内存量。buff表示被用来作为缓存的内存。
swap列:si表示从磁盘交换到内存的交换页数量。so表示从内存交换到磁盘的交换页数量。
io列:bi表示发送到块设备的块数,单位:块/秒。bo表示从块设备接收到的块数。
system列:in表示每秒的中断数,包括时钟中断。cs表示每秒的上下文切换次数。
cpu列:us表示用户cpu使用时间。sy表示内核cpu系统使用时间。id表示空闲时间。 wa表示等待io时间。
3. Linux - iostat工具
可以提供详尽的I/O信息。
如果只看磁盘信息,可以使用-d参数。即:Iostat –d 1 3 (每1秒采集一次持续3次)
tps列表示该设备每秒的传输次数。
Blk_read/s列表示每秒读取块数。
Blk_wrtn/s列表示每秒写入块数。
Blk_read列表示读取块数总量。
Blk_wrtn列表示写入块数总量。
iostat 1 1
4. JDK工具 - jps
用于列出Java的进程。
执行语法: jps [-options]
jps 列出java进程id和类名,输出:91275 FireIOTest
jps –q 仅列出java进程id,输出:91275
jps –m 输出java进程的入参,输出:91730 FireIOTest a b
jps –l 输出主函数的完整路径,输出:91730 day1.FireIOTest
jps –v 显示传递给JVM的参数,输出:91730 FireIOTest -Xmx512m -XX:+PrintGC - javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=51673:/Applications/IntelliJ IDEA.app/Contents/bin - Dfile.encoding=UTF-8
5. JDK工具 - jstat
用于查看堆中的运行信息。
执行语法:jstat –help jstat -options
jstat <-option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]
jstat -class -t 73608 1000 5 查看进程73608的ClassLoader相关信息,每1000毫秒打印1 次,一共打印5次,并输出程序启动到此刻的Timestamp数。
jstat -compiler -t 73608 查看指定进程的编译信息。
jstat -gc 73608 查看指定进程的堆信息。
jstat -gccapacity 73608 查看指定进程中每个代的容量与使用情况
jstat -gccause 73608 显示最近一次gc信息
jstat -gcmetacapacity 73608 查看指定进程的元空间使用信息
jstat -gcnew 73608 查看指定进程的新生代使用信息
jstat -gcnewcapacity 73608 查看指定进程的新生代各区大小信息
jstat -gcold 73608 查看指定进程的老年代使用信息
jstat -gcoldcapacity 73608 查看指定进程的老年代各区大小信息
jstat -gcutil 73608 查看指定进程的GC回收信息
jstat -printcompilation 73608 查看指定进程的JIT编译方法统计信息
6. JDK工具 - jinfo
用于查看运行中java进程的虚拟机参数。
执行语法: jinfo [option] <pid>
jinfo -flag MaxTenuringThreshold 73608 查看进程73608的虚拟机参MaxTenuringThreshold 的值
jinfo -flag +PrintGCDetails 73608 动态添加进程73608的虚拟机参数+PrintGCDetails,开启 GC日志打印。
jinfo -flag -PrintGCDetails 73608 动态移除进程73608的虚拟机参数+PrintGCDetails,关闭 GC日志打印。
7. JDK工具 - jmap
命令用于生成指定java进程的dump文件;可以查看堆内对象实例的统计信息,查看 ClassLoader信息和finalizer队列信息。
执行语法: jmap [option] <pid>
jmap -histo 73608 > /Users/muse/a.txt 输出进程73608的实例个数与合计到文件a.txt中
jmap -dump:format=b,file=/Users/muse/b.hprof 73608 输出进程73608的堆快照,可使用 jhat、visual VM等进行分析
8. JDK工具 - jhat
命令用于分析jmap生成的堆快照。
执行语法: jhat [-stack <bool>] [-refs <bool>] [-port <port>] [-baseline <file>] [-debug <int>] [-version] [-h|-help] <file>
jhat b.hprof 分析jmap生成的堆快照b.hprof, http://127.0.0.1:7000通过这个地址查看。 OQL(Object Query Language)
9. JDK工具 - jstack
命令用于导出指定java进程的堆栈信息。
执行语法: jstack [-l] <pid>
jstack -l 73608 > /Users/muse/d.txt 输出进程73608(通过jps命令查的进程id)的实例个数与合计到文件a.txt中
cat /Users/muse/d.txt
10. JDK工具 - jcmd
命令用于导出指定java进程的堆栈信息,查看进程,GC等。
执行语法: jcmd <pid | main class> <command … | PerfCounter.print | -f file>
jcmd -l 列出java进程列表
jcmd 26586 help 输出进程java进程为26586所支持的jcmd指令
jcmd 26586 VM.uptime 查看java进程启动时间
jcmd 26586 Thread.print 打印线程栈信息
jcmd 26586 GC.class_histogram 查看系统中类的统计信息
jcmd 26586 GC.heap_dump ~/a.txt 导出堆信息
jcmd 26586 VM.system_properties 获得系统的Properties内容
jcmd 26586 VM.flags 获得启动参数
jcmd 26586 PerfCounter.print 获得性能统计相关数据
面:G1的特点(全代收集:包括了新生代和老年代)和流程(四个步骤的前两个更重要)
特点是分区收集,分区收集的含义和优势?