当然,可能重启大法解决一切:laughing: ,但是难保不会再出现这种问题。知其错误也要知其why错误

CPU的负载为什么突然升高?

生产环境应用为什么突然卡住?

日志为什么没有输出?

程序对用户的请求为什么没有响应?

让我们走进JVM的内心世界。

学习目标

  • 能够说出java体系、知道什么是java虚拟机、运行原理
  • 能够知道字节码的结构,包含哪些信息,怎么分布的
  • 能够掌握jvm运行时数据区都是怎么划分的,各个部分会有什么异常
  • 能够说出类的加载过程,每个阶段都做了什么事情
  • 能够说明类的创建过程
  • 能够说出垃圾回收策略、机制和原理 、垃圾回收三种算法的优劣
  • 能够说出G1垃圾回收ygc和fullgc以及与JDK8垃圾回收器区别和共同点

1、虚拟机概述

1.1 发展历程

1.1.1 java往事

​ Java诞生在一群懒惰、急躁而傲慢的程序天才之中。

​ 1990年12月,Sun的工程师Patrick Naughton被当时糟糕的Sun C++工具折磨的快疯了。他大声抱怨,并威胁要离开Sun转投当时在Steve Jobs领导之下的NeXT公司。领导层为了留住他,给他一个机会,启动了一个叫做Stealth(秘密行动)的项目。

​ 随着James Gosling等人的加入,这个项目更名为Green。其目标是使用C++为嵌入式设备开发一种新的基础平台技术,James Gosling本人负责开发一个编辑器。正如人们事后分析的那样,这位天才的程序员太懒惰,所以没有把C++学好,开发中碰了一头包。于是他决定开发一种新的编程语言。他把这种语言命名为C++++–,意思是C++ “加上一些好东西,减去一些坏东西”。显然这个糟糕的名字不可能长久,于是很快这种颇受同伴喜爱的小语言被命名为Oak。

​ 到了1992年9月,Oak语言连同Green OS和一些应用程序一起发布在称做Start 7的小设备上,有了第一次精彩的亮相。随后,Sun开了一家名为FirstPerson的公司,整个团队被转移到这家公司里研发机顶盒,以投标时代华纳公司的一个项目。这帮天才被技术狂热所鼓舞,开发出了一个高交互性的设备,结果没想到时代华纳公司和有线电视服务商并不愿意用户拥有那么大的控制权,从而在竞标之战中败给了SGI。

​ Sun无奈地关闭了FirstPerson,召回了整个团队,java的出路却没有因此而断送,随着互联网发展的涌动,java开始离开嵌入式小设备,往互联网倾斜。后Oak被命名为Java,回到了激情澎湃的IT产业,抓住互联网的大潮,从此一发不可收拾。

​ 剩下的事情,大家都知道了……

1.1.2 java版本迭代

  • 1991 年,James Gosling 博士发布产品 Oak( 橡树),这是 Java 语言的前身。
  • 1995 年,Oak 语言改名为 Java。
  • 1996 年,JDK(Java开发所使用的工具包)1.0 发布,提供了纯解释执行的 Java 虚拟机实现,使用外挂进行JIT:Sun JDK 1.0 Classic VM。
  • 1997 年,JDK1.1 发布,代表技术有:JDBC、JavaBeans、内部类、反射。AWT、RMI
  • 1998 年,JDK1.2 发布,Java 技术体系被拆分为 J2SE、J2EE、J2ME 三大体系。加入Swing Collections.Solaris Exact VM。JIT 解释器混合、Accurate Memory Management:精确内存管理,数据类型敏感、提升的GC性能
  • 2000 年,JDK1.3 发布,默认的 Java 虚拟机由 Sun Classic VM 改为 HotSopt。加入JavaSound
  • 2002 年,JDK1.4 发布,Java 真正走向成熟,代表技术有:正则表达式、NIO、IPV6、日志API、加密类库、Assert等。Classic VM退出历史舞台.
  • 2004 年,JDK5.0 发布,对语法易用性做了很大改进,新增了泛型、枚举、装箱、枚举、可变长的参数、Foreach循环 等,代表技术有:并发包等。
  • 2006 年,JDK6.0 发布,将 J2EE/J2SE/J2ME 的命名方式改为 Java SE 6、Java EE 6、Java ME 6。脚本语言支持、JDBC 4.0、Java编译器 API。
  • 2009 年,Sun 公司因为经营不善被 Oracle 公司收购。
  • 2011 年,JDK7 发布。延误项目推出到JDK8 、G1 (GC收集器)、动态语言增强 、64位系统中的压缩指针 、NIO 2.0
  • 2013 年,JDK8(LTS) 发布,函数式编程,lamda表达式,语法增强 Java类型注解。
  • 2017年,JDK9。模块化。
  • 2018年,JDK 10,11(LTS)正式发布
  • 2019年,JDK 12,13
  • 2020年,JDK 14,15
  • 2021年,JDK 16,17(LTS)
1
2
3
4
5
6
7
8
附:sun与微软的轶事
java诞生的1995年,正是微软在软件产业地位达到巅峰的时代。但是这个初出茅庐的毛头小子硬是引起了微软帝国的关注。所以96年微软就向sun申请了java认证。
微软的加持确实推动了人们对java的信心和兴趣。
但是好景不长,从1997年发布Visual J++的第一个版本开始,微软就开始在Java中掺入自己的私有扩展。这毫无疑问引起Sun的高度重视。
1997年10月,Sun向美国加州地方法院起诉微软公司违反两公司就微软使用Java技术所签定的合同,指控微软公司在自己的Java产品中做了“不恰当的修改”,违反了合同中承诺向用户提供Java兼容产品的条款。
这一官司一直打到了2001年1月双方达成和解。
到了2001年7月,微软公布新版的Windows XP将不再支持Sun的JVM,并且推出了.NET平台与Java分庭抗礼。
当然目前.net用的人少了,这是后话。

image-20230928164130440

1.1.3 两种jdk

openjdk vs oraclejdk:

  1. Oracle JDK将更多地关注稳定性,它重视更多的企业级用户,而OpenJDK经常发布以支持其他特性,不太稳定。
  2. Oracle JDK支持长期发布的更改(LTS),而Open JDK仅支持计划和完成下一个发行版。
  3. Oracle JDK根据二进制代码许可协议获得许可,而OpenJDK根据GPL v2许可获得许可。
  4. 2019年1月之后发布的Oracle Java SE 8的公开更新将无法用于商业,但是,OpenJDK是完全开源的,可以自由使用。
  5. Oracle JDK的构建过程基于OpenJDK,因此OpenJDK与Oracle JDK之间没有技术差异。
  6. 顶级公司正在使用Oracle JDK,Open JDK不太受欢迎。
  7. Oracle JDK具有良好的GC选项和更好的渲染器,而OpenJDK具有更少的GC选项
  8. 在响应性和JVM性能方面,Oracle JDK提供了更好的性能。
  9. Oracle JDK在运行JDK时不会产生任何问题,而OpenJDK有时会产生一些问题。
  10. Oracle JDK将从其10.0.X版本将收费,用户必须付费或必须依赖OpenJDK才能使用其免费版本。
  11. Oracle JDK完全由Oracle公司开发,而Open JDK项目由IBM,Apple,SAP AG,Redhat等顶级公司加入和合作。

1.1.4 jvm大事记

使用最为广泛的JVM为HotSpot

  • HotSpot 为Longview Technologies开发 被SUN收购
  • 2006年 Java开源 并建立OpenJDK
    • HotSpot 成为Sun JDK和OpenJDK中所带的虚拟机
  • 2008 年 Oracle收购BEA
    • 得到JRockit VM
  • 2010年Oracle 收购 Sun
    • 得到Hotspot
  • Oracle宣布在JDK8时整合JRockit和Hotspot,优势互补
    • 在Hotspot基础上,移植JRockit优秀特性

1.2 JVM体系

image-20230928164138957

  • JDK(Java Development Kit)是 Java语言的软件开发工具包,也是整个java开发的核心,它包含了JRE和开发工具包
  • JRE(Java Runtime Environment),Java运行环境,包含了JVM和Java的核心类库(Java API)
  • JVM(Java Virtual Machine),Java虚拟机,它是运行在操作系统之上的,它与硬件没有直接的交互.JVM使用软件模拟Java 字节码的指令集

VMWare或者Visual Box都是使用软件模拟物理CPU的指令集

所谓“一次编码,随处运行“正是基于不同系统下的jvm帮你掩盖了系统之间接口的差异:

image-20210322103040674

总结

jdk是开发人员的工具包,它包含了java的运行环境和虚拟机,而一次编写到处运行就是基于jvm

1.3 各种JVM虚拟机

1.3.1 清单

1、KVM

SUN发布

IOS Android前,广泛用于手机系统

2、CDC/CLDC HotSpot

手机、电子书、PDA等设备上建立统一的Java编程接口

J2ME的重要组成部分

3、Apache Harmony

兼容于JDK 1.5和JDK 1.6的Java程序运行平台

与Oracle关系恶劣 退出JCP ,Java社区的分裂

OpenJDK出现后,受到挑战 2011年 退役

没有大规模商用经历

对Android的发展有积极作用

4、Sun Classic VM

世界上第一款商用 Java 虚拟机。

1996年随着Java1.0的发布而发布,JDK1.4时完全被淘汰

5、BEA JRockit

1
专注于服务端应用,号称是世界上最快的JVM

​ 后来被 Oracle收购;Oracle JRockit (原来的 Bea JRockit)

6、IBM公司的 J9VM

全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9

是 IBM 自己开发的一款 JVM

市场定位于HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM

7、HotSpot VM(现在最常用)

​ 它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

8、其他

(TaobaoJVM 、Graal VM、Azul VM、Liquid VM、)虚拟机

1.3.2 查看

1
2
3
4
shawn@macpro:~ > java -version
java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)
  • hotspot虚拟机
  • Client VM是专门为快速启动和小内存(small footprints)而优化的,像GUI就很适合
  • Server VM是专门为高性能应用而优化的,如服务器应用
  • 版本是基于tag为1.8.0_181

1.4 规范

Java语言规范

Java语言规范定义了什么是Java语言

语法定义

1
2
3
4
5
6
IfThenStatement: 
if ( Expression ) Statement

ArgumentList:
Argument
ArgumentList , Argumen

image-20220604163905645

词法结构

  • \u + 4个16进制数字 表示UTF-16
  • 行终结符:CR, or LF, or CR LF.
  • 空白符
    • 空格 tab \t 换页 \f 行终结符
  • 注释
  • 标示符
  • 关键字

image-20220604164221952

  • Int
    • 0 2 0372 0xDada_Cafe 1996 0x00_FF__00_FF
  • Long
    • 0l 0777L 0x100000000L 2_147_483_648L 0xC0B0L
  • Float
    • 1e1f 2.f .3f 0f 3.14f 6.022137e+23f
  • Double
    • 1e1 2. .3 0.0 3.14 1e-9d 1e137
  • 操作
    • += -= *= /= &= |= ^= %= <<= >>= >>>=

变量 和 类型

  • 元类型

    • byte short int long float char
  • 变量初始值

    • boolean false

    • char \u0000

  • 泛型

image-20220604164549468

其他规范

  • Java内存模型
  • 类加载链接的过程
  • public static final abstract的定义
  • 异常
  • 数组的使用

JVM规范

JVM主要定义二进制class文件和JVM指令集等,java语言和jvm相对独立,只要实现了JVM规范的都可以用JVM执行,比如Groovy、Clojure 、Scala

  • Class文件类型

  • 数字的内部表示和存储

    • Byte -128 to 127 (-27 to 27 - 1)
  • returnAddress 数据类型定义

    • 指向操作码的指针。不对应Java数据类型,不能在运行时修改。Finally实现需要
  • 定义PC

  • 方法区

  • 整数的表达:原反补码

  • Float的表示与定义

image-20220604165145141

  • 一些特殊的方法
1
2
<clinit> 
<init>
  • VM指令集
1
2
3
4
5
6
7
8
9
10
– 类型转化 
l2i
– 出栈入栈操作
aload astore
– 运算
iadd isub
– 流程控制
ifeq ifne
– 函数调用
invokevirtual invokeinterface invokespecial invokestatic
  • JVM需要对Java Library 提供以下支持:
1
2
3
4
5
6
- 反射 java.lang.reflect 
- ClassLoader
- 初始化class和interface
- 安全相关 java.security
- 多线程
- 弱引用
  • JVM的编译
1
2
3
4
- 源码到JVM指令的对应格式 
– Javap
– JVM反汇编的格式
<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

image-20220604165525384

1.5 jvm整体架构

1.5.1 java运行过程

image-20210322133820431

1.源码编译:通过Java源码编译器将Java代码编译成JVM字节码(.class文件)

2.类加载:通过ClassLoader及其子类来完成JVM的类加载

3.类执行:字节码被装入内存,进入JVM虚拟机,被解释器解释执行

1.5.2 jvm模型

image-20210322135008844

由上面的图可以看出,JVM虚拟机中主要是由三部分构成,分别是类加载子系统、运行时数据区、执行引擎。

类加载子系统

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

执行引擎

执行引擎用于执行JVM字节码指令,主要有两种方式,分别是解释执行和编译执行,区别在于,解释执行是在执行时翻译成虚拟机指令执行,而编译执行是在执行之前先进行编译再执行。

解释执行启动快,执行效率低。编译执行,启动慢,执行效率高。

垃圾回收器就是自动管理运行数据区的内存,将无用的内存占用进行清除,释放内存资源。

本地方法库、本地库接口

在jdk的底层中,有一些实现是需要调用本地方法完成的(使用c或c++写的方法),就是通过本地库接口调用完成的。比如:System.currentTimeMillis()方法。

PC寄存器

每个线程拥有一个PC寄存器,在线程创建时创建,指向下一条指令的地址,执行本地方法时,PC的值为undefined

方法区

保存装载的类信息

  • 类型的常量池
  • 字段,方法信息
  • 方法字节码

通常和永久区(Perm)关联在一起

  • JDK6时,String等常量信息置于方法
  • JDK7时,已经移动到了堆

Java堆

  • 和程序开发密切相关
  • 应用系统对象都保存在Java堆中
  • 所有线程共享Java堆
  • 对分代GC来说,堆也是分代的
  • GC的主要工作区间

image-20220605081910952

Java栈

  • 线程私有

  • 栈由一系列帧组成(因此Java栈也叫做帧栈)

  • 帧保存一个方法的局部变量、操作数栈、常量池指针

  • 每一次方法调用创建一个帧,并压栈

局部变量表,包含参数和局部变量

image-20220605082130838

函数调用组成帧栈

image-20220605082248826

这是一个帧,省略:操作数栈,返回地址等

操作数栈:Java没有寄存器,所有参数传递使用操作数栈

image-20220605082403503

栈上分配

  • C++代码示例

image-20220605082528328

栈上分配不会出现内存泄露

运用到java中:

image-20220605082625624

  • 小对象(一般几十个bytes),在没有逃逸的情况下,可以直接分配在栈上

  • 直接分配在栈上,可以自动回收,减轻GC压力

  • 大对象或者逃逸对象无法栈上分配

1.5.3 jvm启动流程

image-20220605081532806

1.5.4 堆栈方法区交互

image-20220605083227428

JVM基本结构

内存模型

  • 每一个线程有一个工作内存和主存独立
  • 工作内存存放主存中变量的值的拷贝

image-20220605083513690

当数据从主内存复制到工作存储时,必须出现两个动作:第一,由主内存执行的读(read)操作;第二,由工作内存执行的相应的load操作;当数据从工作内存拷贝到主内存时,也出现两个操作:第一个,由工作内存执行的存储(store)操作;第二,由主内存执行的相应的写(write)操作

每一个操作都是原子的,即执行期间不会被中断

对于普通变量,一个线程中更新的值,不能马上反应在其他变量中。如果需要在其他线程中立即可见,需要使用 volatile 关键字

image-20220605083715043

volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class VolatileStopThread extends Thread{
private volatile boolean stop = false; // 没有volatile -server 运行 无法停止
public void stopMe(){
stop=true;
}

public void run(){
int i=0;
while(!stop){
i++;
}
System.out.println("Stop thread");
}

public static void main(String args[]) throws InterruptedException{
VolatileStopThread t=new VolatileStopThread(); // 不加volatile,这个线程只会在自己本地存储区域查看值,不能做到及时更新
t.start();
Thread.sleep(1000);
t.stopMe();
Thread.sleep(1000);
}
}

volatile 不能代替锁一般认为volatile 比锁性能好(不绝对)

选择使用volatile的条件是:语义是否满足应用

可见性

  • 一个线程修改了变量,其他线程可以立即知道

保证可见性的方法

  • volatile

  • synchronized (unlock之前,写变量值回主存)

  • final(一旦初始化完成,其他线程就可见)

有序性

  • 在本线程内,操作都是有序的

  • 在线程外观察,操作都是无序的。(指令重排 或 主内存同步延时)

指令重排

  • 线程内串行语义

image-20220605084247101

  • 破坏线程间的有序性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class OrderExample {
int a = 0;
boolean flag = false;

public void writer() { // 线程A首先执行writer()方法
a = 1;
flag = true;
}

public void reader() { // 线程B线程接着执行reader()方法
if (flag) {
int i = a +1; // 线程B在int i=a+1 是不一定能看到a已经被赋值为1.因为在writer中,两句话顺序可能打乱
……
}
}
}

image-20220605084406430

  • 保证有序性的方法

image-20220605084450167

  • 指令重排基本原则

程序顺序原则:一个线程内保证语义的串行性

volatile规则:volatile变量的写,先发生于读

锁规则:解锁(unlock)必然发生在随后的加锁(lock)前

传递性:A先于B,B先于C 那么A必然先于C

线程的start方法先于它的每一个动作

线程的所有操作先于线程的终结(Thread.join())

线程的中断(interrupt())先于被中断线程的代码

对象的构造函数执行结束先于finalize()方法

解释运行

  • 解释执行以解释方式运行字节码

  • 解释执行的意思是:读一句执行一句

编译运行(JIT)

  • 将字节码编译成机器码

  • 直接执行机器码

  • 运行时编译

  • 编译后性能有数量级的提升

2、类文件结构

了解jvm后续的一切动作,先从字节码开始。它是一切发生的源头。

2.1 测试案例

2.1.1 源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* 基本类结构
* */
public class ClassStruct {

private static String name = "JVM";


private static final int age = 18;

public static void main(String[] args) {
System.out.println("Hello " + name);
}

}

2.1.2 编译

1)maven定义编译的版本

1
2
3
4
5
6
7
8
9
10
11
12
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>

2)编译

1
mvn clean compile

2.2 字节码结构

和语言无关

image-20220606182947329

2.2.1 二进制概览

1)vscode打开

image-20210322174708164

2)class文件是一个二进制文件,转化后是16进制展示,实际上class文件就是一张表,它由以下数据项构成,这些数据项从头到尾严格按照以下顺序排列:

类型 名称 数量 描述
u4 magic 1 魔数
u2 minor_version 1 次版本号
u2 major_version 1 主版本号
u2 constant_pool_count 1 常量计数
cp_info constant_pool constant_pool_count - 1 具体常量
u2 access_flags 1 访问标志
u2 this_class 1 类索引
u2 super_class 1 父类索引
u2 interfaces_count 1 接口索引
u2 interfaces interfaces_count 具体接口
u2 fields_count 1 字段个数
field_info fields fields_count 具体字段
u2 methods_count 1 方法个数
method_info methods methods_count 具体方法
u2 attributes_count 1 属性个数
attribute_info attributes attributes_count 具体属性

3)图示如下:

image-20230928164236853

a

2.2.2 魔数与版本

1)魔数:

CAFEBABE,咖啡宝宝,固定的。

image-20210322175549121

2)版本号:

34,换成10进制就是52

image-20210322175609021

jdk的版本标记映射关系:

image-20220606183542274

image-20210322172801851

说明编译用的是jdk8,我们改成1.6,重新执行 mvn clean compile ,再来查看class文件试试:

1
2
3
4
5
6
7
8
9
10
11
12
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.6</source>
<target>1.6</target>
</configuration>
</plugin>
</plugins>
</build>

扩展

在开发中,经常会遇到类似Unsupported major.minor version 51.0的错误,一般情况下都是JDK版本不匹配造成的。 虽然jdk代码在执行时基本上向下兼容,但是!开发环境和服务器环境jdk最好一致,不要尝试这个坑。

区分和理解两个环境:编译环境,运行环境

2.2.3 常量池

再往下遵从相同的规律: 计数器(标注后面有多少个) + 对应个数的结构体

我们以常量池为例:

1)位置

image-20210322175628817

2)结构说明

常量池记录了jvm内的一堆常量信息,这部分由 【2个字节计数】 + 【n个cp_info结构】组成

image-20210329105800328

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。 字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。 而符号引用则属于编译原理方面的概念,包括了下面三类常量: 类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符

其中cp_info有多种类型:

  • 直接类型,存的就是当前值,这种像Integer,Long等长度都是确定的
  • 引用类型,存的是指向其他位置的指针

image-20210329105546453

附:绿色代表指针,橙色代表直接类型

具体说明:

image-20220606183820312

CONSTANT_Utf8

1
2
3
tag 1
length u2
bytes[length]

image-20220606184302779

CONSTANT_Integer

1
2
tag 3
byte u4

image-20220606184358909

3)案例

下面以String为例,String是一种引用类,它会指向一个utf8类型来存储真实的信息

jdk提供了一个工具,javap,可以查看常量列表的详细内容:

1
javap -v ClassStruct.class

image-20210329104743038

image-20220606184050633

2.2.4 其他信息

1)说明

常量池之后,是紧挨的一系列信息,这些信息大同小异,无非就是值、或者引用

(参考上面2.3.3里的表格和图例)

  • 访问标记:public abstract 类的标示符等信息

image-20220606184820692

  • 类索引,class类型,最终指向一个utf8,标记当前类的名字
  • 父类,同上
  • 接口,2字节记录数量,后面记录多个接口类型
  • 接下来是字段、方法、属性,都是2字节记录后面多少个,后面紧跟对应的结构体类型

CONSTANT_NameAndType

image-20220606184610285

CONSTANT_Class

image-20220606184637200

CONSTANT_Fieldref ,CONSTANT_Methodref ,CONSTANT_InterfaceMethodref

image-20220606184659735

image-20220606184716425

2)注意事项

要看懂javap后的格式,明白这些格式,可以轻松看懂class结构

image-20210329114350327

类型 标识符 案例 说明
数组 [ [Ljava.lang.String String数组
对象 L Lcom.test.Demo
基本类型 大写字母开头 B=byte,I=int……

组合类型

类型 案例 说明
类里的属性、字段、方法等 com.test.Demo.name:Ljava.lang.String 英文点号隔开
标识什么类型 com.test.Demo.getName:()Ljava.lang.String 英文冒号隔开
方法 (参数类型)返回值类型 英文括弧,后面是返回值类型

3)实例分析

image-20210329114932971

User类例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class User {
private int id;
private String name;
private int age;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
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;
}

}

image-20220606185129410

image-20220606185153807

image-20220606185211781

image-20220606185227953

3、运行数据区

字节码只是一个二进制文件存放在那里。要想在jvm里跑起来,先得有个运行的内存环境。

也就是我们所说的jvm运行时数据区。

1)运行时数据区的位置

运行时数据区是jvm中最为重要的部分,执行引擎频繁操作的就是它。类的初始化,以及后面我们讲的对象空间的分配、垃圾的回收都是在这块区域发生的。

image-20210325135403075

2)区域划分

根据《Java虚拟机规范》中的规定,在运行时数据区将内存细分为几个部分

线程私有的:Java虚拟机栈(Java Virtual Machine Stack)、程序计数器(Program Counter Register)、本地方法栈(Native Method Stacks)

大家共享的:方法区(Method Area)、Java堆区(Java Heap)

image-20210329093556986

接下来我们分块详细来解读,每一块是做什么的,如果溢出了会发生什么事情

3.1 程序计数器

3.1.1 概述

程序计数器(Program Counter Register)

  • 每个线程一个。是一块较小的内存空间,它表示当前线程执行的字节码指令的地址。

  • 字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,所以整个程序无论是分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 由于线程是多条并行执行的,互相之间执行到哪条指令是不一样的,所以每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

  • 如果是native方法,这里为空

3.1.2 溢出异常

没有!

在虚拟机规范中,没有对这块区域设定内存溢出规范,也是唯一一个不会溢出的区域

3.1.3 案例

因为它不会溢出,所以我们没有办法给它造一个,但是从class类上可以找到痕迹。

回顾上面javap的反汇编,其中code所对应的编号就可以理解为计数器中所记录的执行编号。

image-20210324101225120

3.2 虚拟机栈

image-20210324103100187

3.2.1 概述

  • 也是线程私有的!生命周期与线程相同。
  • 它描述的是Java方法执行的当前线程的内存模型,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

3.2.2 溢出异常

1)栈深度超出设定

如果是创建的栈的深度大于虚拟机允许的深度,抛出

Exception in thread “main” java.lang.StackOverflowError

2)内存申请不足

如果栈允许内存扩展,但是内存申请不够的时候,抛出 OutOfMemoryError

注意!这一点和具体的虚拟机有关,hotspot虚拟机并不支持栈空间扩展,所以单线程环境下,一个线程创建时,分配给它固定大小的一个栈,在这个固定栈空间上不会出现再去扩容申请内存的情况,也就不会遇到申请不到一说,只会因为深度问题超出固定空间造成上面的StackOverflowError

如果换成多线程,毫无节制的创建线程,还是有可能造成OutOfMemoryError。但是这个和Xss栈空间大小无关。是因为线程个数太多,栈的个数太多,导致系统分配给jvm进程的物理内存被吃光。

这时候虚拟机会附带相关的提示:

Exception in thread “main” java.lang.OutOfMemoryError: unable to create native thread

ps: 每个线程默认分配1M空间(64位linux,hotspot环境)

疑问:是不是改小Xss的值就可以得到栈空间溢出呢?

答:根据上面的分析,hotspot下不可以,还是会抛出StackOverflowError,无非深度更小了。

3.2.3 案例一:进出栈顺序

1)代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.itheima.jvm.demo;

/**
* 程序模拟进栈、出栈过程
* 先进后出
*/
public class StackInAndOut {
/**
* 定义方法一
*/
public static void A() {
System.out.println("进入方法A");
}

/**
* 定义方法二;调用方法一
*/
public static void B() {
A();
System.out.println("进入方法B");
}

public static void main(String[] args) {

B();
System.out.println("进入Main方法");
}
}



2)运行结果:

1
2
3
进入方法A
进入方法B
进入Main方法

3)栈结构:

main方法—->B方法—->A方法

image-20210324104530470

3.2.4 案例二:栈深度溢出

1)代码

这个容易实现,方法嵌套自己就可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.itheima.jvm.demo;

/**
* 通过一个程序模拟线程请求的栈深度大于虚拟机所允许的栈深度;
* 抛出StackOverflowError
*/
public class StackOverFlow {
/**
* 定义方法,循环嵌套自己
*/
public static void B() {
B();
System.out.println("进入方法B");
}

public static void main(String[] args) {

B();
System.out.println("进入Main方法");
}
}


2)运行结果:

1
2
3
4
5
6
Exception in thread "main" java.lang.StackOverflowError
at com.itheima.jvm.demo.StackOverFlow.B(StackOverFlow.java:12)
at com.itheima.jvm.demo.StackOverFlow.B(StackOverFlow.java:12)
at com.itheima.jvm.demo.StackOverFlow.B(StackOverFlow.java:12)
at com.itheima.jvm.demo.StackOverFlow.B(StackOverFlow.java:12)
at com.itheima.jvm.demo.StackOverFlow.B(StackOverFlow.java:12)

3)栈结构:

image-20210324105125093

3.2.5 案例三:栈内存溢出

一直不停的创建线程就可以堆满栈

但是!这个很危险,到32系统的winxp上勇敢的小伙伴可以试一试,机器卡死不负责!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.itheima.jvm.demo;

/*
* 栈内存溢出,注意!很危险,谨慎执行
* 执行时可能会卡死系统。直到内存耗尽
* */
public class StackOutOfMem {
public static void main(String[] args) {
while (true) {
new Thread(() -> {
while(true);
}).start();
}
}
}

3.3 本地方法栈

3.3.1 概述

  • 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点
  • 不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法
  • 虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以自由实现它
  • 甚至,hotspot把它和虚拟机栈合并成了1个

3.3.2 溢出异常

和虚拟机栈一样,也是两个:

如果是创建的栈的深度大于虚拟机允许的深度,抛出 StackOverFlowError

内存申请不够的时候,抛出 OutOfMemoryError

3.4 堆

3.4.1 概述

与上面的3个不同,堆是所有线程共享的!所谓的线程安全不安全也是出自这里。

在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。

需要注意的是,《Java虚拟机规范》并没有对堆进行细致的划分,所以对于堆的讲解要基于具体的虚拟机,我们以使用最多的HotSpot虚拟机为例。

Java堆是垃圾收集器管理的内存区域,因此它也被称作“GC堆”,这就是我们做JVM调优的重点区域部分。

3.4.2 jdk1.7

jvm的内存模型在1.7和1.8有较大的区别,虽然1.7目前使用的较少了,但是我们也是需要对1.7的内存模型有所了解,所以接下里,我们将先学习1.7再学习1.8的内存模型。

image-20200421111048271

  • Young 年轻区(代)

    Young区被划分为三部分,Eden区和两个大小严格相同的Survivor区

    其中,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用

    在Eden区间变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到下面的Tenured区间。

并不是所有new的对象都会进入到年轻代,如果是大对象会直接去老年代。

  • Tenured 年老区

    Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区,一般如果系统中用了application级别的缓存,缓存中的对象往往会被转移到这一区间。

  • Perm 永久区

    现在已经成为历史,Perm代主要保存类信息,class,method,filed等对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到java.lang.OutOfMemoryError : PermGen space 的错误,造成这个错误的很大原因就有可能是每次都重新部署,但是重新部署后,类的class没有被卸载掉,这样就造成了大量的class对象保存在了perm中,这种情况下,一般重新启动应用服务器可以解决问题。另外一种可能是创建了大批量的jsp文件,造成类信息超出perm的上限而溢出。这种重启也解决不了。只能调大空间。

3.4.3 jdk1.8

image-20210325104219429

由上图可以看出,jdk1.8的内存模型是由2部分组成,年轻代 + 年老代。永久代被干掉,换成了Metaspace(元数据空间)

年轻代:Eden + 2*Survivor (不变)

年老代:OldGen (不变)

元空间:原来的perm区 (重点!)

需要特别说明的是:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中,这也是与1.7的永久代最大的区别所在

image-20200421114248164

image-20220830183650282

jdk1.8移除老年代的动机: JRockit 和 Hotspot 融合工作的一部分。 JRockit客户不需要配置永久代(因为JRockit没有永久代),习惯不配置永久代。

实际使用时由于永久代内存经常不够用/发生内存泄漏,报出异常OOM:PermGen。所以将永久区废弃,改用元空间,改为了使用本地内存空间。

3.4.4 jstat查看堆内存使用情况

jstat [-命令选项][VMid][间隔时间/毫秒][查询次数]

  • 查看class加载统计: jstat -class 12112

image-20220830184731968

1
2
3
4
5
loaded :加载class的数量
bytes: 占用空间的大小
unloaded:还没加载的数量
bytes: 还没加载的数量的大小
time: 加载耗时
  • 查看编译统计: jstat -compiler 12112

image-20220830185043741

1
2
3
4
5
6
compiled: 编译数量
failed: 失败数量
invalid: 无效数量
time: 时间
failedType: 失败类型
failedMethod: 失败的方法
  • 查看垃圾回收统计: jstat -gc 12112

image-20220830185209013

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
S0C:第一个Survivor区的大小(KB)
S1C:第二个Survivor区的大小(KB)
S0U:第一个Survivor区的使用大小(KB)
S1U:第二个Survivor区的使用大小(KB)
EC:Eden区的大小(KB)
EU:Eden区的使用大小(KB)
OC:Old区大小(KB)
OU:Old使用大小(KB)
MC:方法区大小(KB)
MU:方法区使用大小(KB)
CCSC:压缩类空间大小(KB)
CCSU:压缩类空间使用大小(KB)
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间

每1s打印一次,一共打印十次,可以实时查看:jstat -gc 12112 1000 10

3.4.5 溢出异常

内存不足时,抛出

java.lang.OutOfMemoryError: Java heap space

3.4.6 案例:堆溢出

1)代码

分配大量对象,超出jvm规定的堆范围即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.itheima.jvm.demo;

import java.util.ArrayList;
import java.util.List;

/**
* 堆溢出
* -Xms20m -Xmx20m
*/
public class HeapOOM {
Byte[] bytes = new Byte[1024*1024];
public static void main(String[] args) {
List list = new ArrayList();
int i = 0;
while (true) {
System.out.println(++i);
list.add(new HeapOOM());
}
}
}

2)启动

注意启动时,指定一下堆的大小:

image-20210325135056497

2)输出

1
2
3
4
5
6
7
8
9
1
2
3
4
5
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.itheima.jvm.demo.HeapOOM.<init>(HeapOOM.java:7)
at com.itheima.jvm.demo.HeapOOM.main(HeapOOM.java:13)

3.5 方法区

3.5.1 概述

同样,线程共享的。

它主要用来存储类的信息、类里定义的常量、静态变量、编译器编译后的代码缓存。

注意!方法区在虚拟机规范里这是一个逻辑概念,它具体放在那个区域里没有严格的规定。

所以,hotspot 1.7- 将它放在了堆的永久代里,1.8+单独开辟了一块叫metaspace来存放一部分内容(不是全部!定义的类对象在堆里)

具体方法区主要存什么东西呢?粗略的分,可以划分为两类:

  • 类信息:主要指类相关的版本、字段、方法、接口描述、引用等

  • 运行时常量池:编译阶段生成的常量与符号引用、运行时加入的动态变量

    (常量池里的类变量,如对象或字符串,比较特殊,1.6和1.8位置不同,下面会讲到)

小提示:

这里经常会跟上面堆里的永久代混为一谈,实际上这是两码事

永久代是hotspot在1.7及之前才有的设计,1.8+,以及其他虚拟机并不存在这个东西。

可以说,永久代是1.7的hotspot偷懒的结果,他在堆里划分了一块来实现方法区的功能,叫永久代。因为这样可以借助堆的垃圾回收来管理方法区的内存,而不用单独为方法区再去编写内存管理程序。懒惰!

同时代的其他虚拟机,如J9,Jrockit等,没有这个概念。后来hotspot认识到,永久代来做这件事不是一个好主意。1.7已经从永久代拿走了一部分数据,直到1.8+彻底去掉了永久代,方法区大部分被移到了metaspace(再强调一下,不是全部!)

结论:

方法区是一定存在的,这是虚拟机规定的,但是是个逻辑概念,在哪里虚拟机自己去决定

而永久代不一定存在,已成为历史

3.5.2 溢出异常

1.6:OutOfMemoryError: PermGen space

1.8:OutOfMemoryError: Metaspace

3.5.3 案例:1.6方法区溢出

1)原理

image-20210330111421370

在1.6里,字符串常量是运行时常量池的一部分,也就是归属于方法区,放在了永久代里。

所以1.6环境下,让方法区溢出,只需要可劲造往字符串常量池中造字符串即可,这里用到一个方法:

1
2
3
4
5
/*
如果字符串常量池里有这个字符串,直接返回引用,不再额外添加
如果没有,加进去,返回新创建的引用
*/
String.intern()

2)代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 方法区溢出,注意限制一下永久代的大小
* 编译的时候注意pom里的版本,要设置1.6,否则启动会有问题
* jdk1.6 : -XX:PermSize=6M -XX:MaxPermSize=6M
*/
public class ConstantOOM {

public static void main(String[] args) {
ConstantOOM oom = new ConstantOOM();
Set<String> stringSet = new HashSet();
int i = 0;
while (true) {
System.out.println(++i);
stringSet.add(String.valueOf(i).intern());
}
}
}

3)创建启动环境

image-20210326141144524

4)异常信息:

1
2
3
4
5
6
7
...
19118
19119
19120
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at com.itheima.jvm.demo.ConstantOOM.main(ConstantOOM.java:19)

2.5.4 案例:1.8方法区溢出

1)到了1.8,情况发生了变化

可以测试一下,1.8下无论指定下面的哪个参数,常量池运行都不会溢出,会一直打印下去

1
2
-XX:PermSize=6M -XX:MaxPermSize=6M
-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M

2)配置运行环境

image-20210326141912946

3)控制台信息

不会抛出异常,只要你jvm堆内存够,理论上可以一直打下去

image-20210326141956360

4)为什么呢?

永久代我们加了限制,结果没意义,因为1.8里已经没有这货了

元空间也加了限制,同样没意义,那说明字符串常量池它不在元空间里!

那么,它在哪里呢?

image-20210330111458021

jdk1.8以后,字符串常量池被移到了堆空间,和其他对象一样,接受堆的控制。

其他的运行时的类信息、基本数据类型等在元空间。

我们可以验证一下,对上面的运行时参数再加一个堆上限限制:

1
2
-Xms10m
-Xmx10m

运行环境如下:

image-20210330143017610

运行没多久,你会得到以下异常:

1
2
3
4
5
6
7
8
9
10
11
12
……
84014
84015
84016
84017
84018
84019
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.lang.Integer.toString(Integer.java:403)
at java.lang.String.valueOf(String.java:3099)
at com.itheima.jvm.demo.ConstantOOM.main(ConstantOOM.java:18)

说明:1.8里,字符串inter()被放在了堆里,受最大堆空间的限制。

5)那如何才能让元空间溢出呢?

既然字符串常量池不在这里,那就换其他的。类的基本信息总在元空间吧?我们来试一下

cglib是一个apache下的字节码库,它可以在运行时生成大量的对象,我们while循环同时限制metaspace试试:

附:https://gitee.com/mirrors/cglib (想深入了解这个工具的猛击左边,这里不做过多讨论)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.itheima.jvm.demo;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

/**
* jdk8方法区溢出
* -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
*/
public class ConstantOOM8 {
public static void main(final String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(objects,args);
}
});
enhancer.create();
}
}

static class OOM{

}
}

6)运行设置

image-20210326152043324

7)运行结果

1
2
3
4
Caused by: java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)

结论:

jdk8引入元空间来存储方法区后,内存溢出的风险比历史版本小多了,但是在类超出控制的时候,依然会打爆方法区

3.6 一个案例

为便于大家理解和记忆,下面我们用一个案例,把上面各个区串通起来。

假设有个Bootstrap的类,执行main方法。在jvm里,它从class文件到跑起来,大致经过如下步骤:

image-20210401175034807

  1. 首先JVM会先将这个Bootstrap.class 信息加载到内存中的方法区
  2. 接着,主线程开辟一块内存空间,准备好程序计数器pc,虚拟机栈、本地方法栈
  3. 然后,JVM会在Heap堆上为Bootstrap.class 创建一个Bootstrap.class 的类实例
  4. JVM开始执行main方法,这时在虚拟机栈里为main方法创建一个栈帧
  5. main方法在执行的过程之中,调用了greeting方法,则JVM会为greeting方法再创建一个栈帧,推到虚拟机栈顶,在main的上面,每次只有一个栈帧处于活动状态,当前为greeting
  6. 当greeting方法运行完成后,则greeting方法出栈,当前活动帧指向main,方法继续往下运行

3.7 归纳总结

image-20230928164252806

1)独享/共享的角度:

  • 独享:程序计数器、虚拟机栈、本地方法栈
  • 共享:堆、方法区

2)error的角度:

  • 程序计数器:不会溢出,比较特殊,其他都会
  • 两个栈:可能会发生两种溢出,一是深度超了,报StackOverflowError,空间不足:OutOfMemoryError
  • 堆:只会在空间不足时,报OutOfMemoryError,会提示heapSpace
  • 方法区:空间不足时,报OutOfMemoryError,提示不同,1.6是permspace,1.8是元空间,和它在什么地方有关

3)归属:

  • 计数器、虚拟机栈、本地方法栈:线程创建必须申请配套,真正的物理空间
  • 堆:真正的物理空间,但是内部结构的划分有变动,1.6有永久代,1.8被干掉
  • 方法区:最没归属感的一块,原因就是它是一个逻辑概念。1.6被放在了堆的永久代,1.8被拆分,一部分在元空间,一部分(方法区的运行时常量池里面的类对象,包括字符串常量,被设计放在了堆里)
  • 直接内存:这块实际上不属于运行时数据区的一部分,而是直接操作物理内存。在nio操作里DirectByteBuffer类可以对native操作,避免流在堆内外的拷贝。我们下一步的调优不会涉及到它,了解即可。

扩展 :

使用ASM生成Java字节码

JIT及其相关参数

4、类加载

通过字节码,我们了解了class文件的结构

通过运行数据区,我们了解了jvm内部的内存划分及结构

接下来,让我们看看,字节码怎么进入jvm的内存空间,各自进入那个空间,以及怎么跑起来。

image-20210331111333565

4.1 加载

4.1.1 概述

类的加载就是将class文件中的二进制数据读取到内存中,然后将该字节流所代表的静态数据结构转化为方法区中运行的数据结构,并且在堆内存中生成一个java.lang.Class对象作为访问方法区数据结构的入口。

image-20210330161050813

注意:

  • 加载的字节码来源,不一定非得是class文件,可以是符合字节码规范的任意地方,甚至二进制流等
  • 从字节码到内存,是由加载器(ClassLoader)完成的,下面我们详细看一下加载器相关内容

4.1.2 系统加载器

jvm提供了3个系统加载器,分别是Bootstrp loaderExtClassLoaderAppClassLoader

这三个加载器互相成父子继承关系

image-20210330164000447

1)Bootstrp loader

Bootstrp加载器是用C++语言写的,它在Java虚拟机启动后初始化

它主要负责加载以下路径的文件:

  • %JAVA_HOME%/jre/lib/*.jar

  • %JAVA_HOME%/jre/classes/*

  • -Xbootclasspath参数指定的路径

1
System.out.println(System.getProperty("sun.boot.class.path"));

2)ExtClassLoader

ExtClassLoader是用Java写的,具体来说就是 sun.misc.Launcher$ExtClassLoader

ExtClassLoader主要加载:

  • %JAVA_HOME%/jre/lib/ext/*
  • ext下的所有classes目录
  • java.ext.dirs系统变量指定的路径中类库
1
System.getProperty("java.ext.dirs")

3)AppClassLoader

AppClassLoader也是用Java写成的,它的实现类是 sun.misc.Launcher$AppClassLoader,另外我们知道ClassLoader中有个getSystemClassLoader方法,此方法返回的就是它。

  • 负责加载 -classpath 所指定的位置的类或者是jar文档
  • 也是Java程序默认的类加载器
1
System.getProperty("java.class.path")

4)验证

很简单,使用一段代码打印对应的property信息就可以查到当前三个类加载器所加载的目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.itheima.jvm.load;

public class SystemLoader {
public static void main(String[] args) {
String[] bootstrap = System.getProperty("sun.boot.class.path").split(":");
String[] ext = System.getProperty("java.ext.dirs").split(":");
String[] app = System.getProperty("java.class.path").split(":");

System.out.println("bootstrap:");
for (String s : bootstrap) {
System.out.println(s);
}

System.out.println();

System.out.println("ext:");
for (String s : ext) {
System.out.println(s);
}

System.out.println();

//app是默认加载器,注意启动控制台的 -classpath 选项
System.out.println("app:");
for (String s : app) {
System.out.println(s);
}

}
}

4.1.3 自定义加载器

除了上面的系统提供的3种loader,jvm允许自己定义类加载器,典型的在tomcat上:

image-20210331100234007

拓展:感兴趣的同学也可以自己写一下,继承ClassLoader这个抽象类,并覆盖对应的findClass方法即可

接下来我们看一个重点:双亲委派

4.1.4 双亲委派

1)概述

image-20210331091443372

类加载器加载某个类的时候,因为有多个加载器,甚至可以有各种自定义的,他们呈父子继承关系。

这给人一种印象,子类的加载会覆盖父类,其实恰恰相反!

与普通类继承属性不同,类加载器会优先调父类的load方法,如果父类能加载,直接用父类的,否则最后一步才是自己尝试加载,从源代码上可以验证。

ClassLoader.loadClass()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先,检测是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
//如果没有加载,开始按如下规则执行:
long t0 = System.nanoTime();
try {
if (parent != null) {
//重点!父加载器不为空则调用父加载器的loadClass
c = parent.loadClass(name, false);
} else {
//父加载器为空则调用Bootstrap Classloader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
//父加载器没有找到,则调用findclass,自己查找并加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}


2)为什么这么设计呢?

避免重复加载、 避免核心类篡改

采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。

其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java。

API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class

即便是父类没加载,也会优先让父类去加载特定系统目录里的class,你获取到的依然是jvm内的核心类,而不是你胡乱改写的。这样便可以防止核心API库被随意篡改。

查找 app -> ext -> boot,找不到,加载 boot->ext->app

反射可以修改,从app加载。是否只能用反射?

双亲模式的破坏

双亲模式是默认的模式,但不是必须这么做。(有这种机制存在,java才能做到热替换,以及更加灵活)

  • Tomcat的WebappClassLoader 就会先加载自己的Class,找不到再委托parent
  • OSGi的ClassLoader形成网状结构,根据需要自由加载Class

先从底层ClassLoader加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// First, check if the class has already been loaded
Class re=findClass(name);
if(re==null){
System.out.println(“无法载入类:”+name+“ 需要请求父加载器");
return super.loadClass(name,resolve);
}
return re;
}
protected Class<?> findClass(String className) throws ClassNotFoundException {
Class clazz = this.findLoadedClass(className);
if (null == clazz) {
try {
String classFile = getClassFile(className);
FileInputStream fis = new FileInputStream(classFile);
FileChannel fileC = fis.getChannel();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
WritableByteChannel outC = Channels.newChannel(baos);
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// 省略部分代码
fis.close();
byte[] bytes = baos.toByteArray();

clazz = defineClass(className, bytes, 0, bytes.length);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
return clazz;
}

image-20220606075858080

双亲模式存在的问题?

顶层ClassLoader,无法加载底层ClassLoader的类

那java框架(rt.jar)是如何加载应用的类的?

​ javax.xml.parsers包中定义了xml解析的类接口Service Provider Interface

​ SPI 位于rt.jar,即接口在启动ClassLoader中,而SPI的实现类,在AppLoader中。

解决方案:上下文ClassLoader

4.1.5 上下文加载器

Thread. setContextClassLoader()

  • 上下文加载器
  • 是一个角色
  • 用以解决顶层ClassLoader无法访问底层ClassLoader的类的问题
  • 基本思想是,在顶层ClassLoader中,传入底层ClassLoader的实例

代码来自于javax.xml.parsers.FactoryFinder展示如何在启动类加载器加载AppLoader的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static private Class getProviderClass(String className, ClassLoader cl,
boolean doFallback, boolean useBSClsLoader) throws ClassNotFoundException
{
try {
if (cl == null) {
if (useBSClsLoader) {
return Class.forName(className, true, FactoryFinder.class.getClassLoader());
} else {
cl = ss.getContextClassLoader();
if (cl == null) {
throw new ClassNotFoundException();
}
else {
return cl.loadClass(className); //使用上下文ClassLoader
}
}
}
else {
return cl.loadClass(className);
}
}
catch (ClassNotFoundException e1) {
if (doFallback) {
// Use current class loader - should always be bootstrap CL
return Class.forName(className, true, FactoryFinder.class.getClassLoader());
}
…..

上下文ClassLoader可以突破双亲模式的局限性

热替换

当一个class被替换后,系统无需重启,替换的类立即生效

可以用classLoader相关知识解决

4.2 验证

加载完成后,class里定义的类结构就进入了内存的方法区。

而接下来,验证是连接阶段的第一步。实际上,验证和上面的加载是交互进行的(比如class文件格式验证)。

而之所以把验证放在加载的后面,是因为除了基本的class文件格式,还需要其他很多验证,我们逐个来看:

4.2.1 文件格式验证

这个好理解,就是验证加载的字节码是不是符合规范

  • 是不是CAFEBABYE开头
  • 主次版本号是否在当前jvm虚拟机可运行的范围内
  • 常量池类型对不对
  • 有没有其他不可识别的信息
  • ……等

总之,根据我们上节讲的字节码分析,要满足合法的字节码约束

4.2.2 元数据验证

到java语法级别了。这个阶段主要验证属性、字段、类关系、方法等是否合规

  • 是否有父类?除了Object其他类必须有
  • 是否继承了不该被继承的类,比如final
  • 是不是抽象类,是的话,方法都完备了没
  • 字段有没问题?是不是覆盖了父类里的final
  • ……等

总之,经过这个阶段,你的类对象结构是ok的了

4.2.3 字节码验证

最复杂的一个阶段。

等等,字节码前面不是验证过了吗?咋还要验证?

上面的验证是基本字节表格式验证。而这里主要验证class里定义的方法,看方法内部的code是否合法。

  • 类型转换是不是有问题?
  • 指令是否跳到了方法外的字节码上?
  • ……

经过本阶段,可以确保你的代码执行时,不会发生大的意外

注意!不是完全不会发生。比如你写了一段代码,jvm只会知道你的方法执行时符合系统规则。

它也不知道你会不会执行很长很长时间导致系统卡死

4.2.4 符号引用验证

最后一个阶段。

这个阶段也好理解,我们上面的字节码解读时,知道字节码里有的是直接引用,有的是指向了其他的字节码地址。

而符号引用验证的就是,这些引用的对应的内容是否合法。

  • utf8里记了某个类的名字,这个类存在不?
  • 方法或字段引用,这些方法在对应的类里存在不存在?
  • 类、字段、方法等上面的可见性是否合法
  • ……

4.3 准备

这个阶段为class中定义的各种类变量分配内存,并赋初始值。

所做的事情好理解,但是要注意几点:

4.3.1 变量类型

注意是类变量,也就是类里的静态变量,而不是new的那些实例变量。new的在下面的初始化阶段

  • 类变量 = 静态变量
  • 实例变量 = 实例化new出来的那些

4.3.2 存储位置

理论上这些值都在方法区里,但是注意,方法区本身就是一个逻辑概念。

1.6里,在永久代

1.8以后,静态类变量如果是一个对象,其实它在堆里。这个上面我们讲方法区的时候验证过。

4.3.3 初始化值

这个值进入了内存,那到底内存里放的value是啥?

注意!

即便是static变量,它在这个阶段初始化进内存的依然是它的初始值!

而不是你想要什么就是什么。

看下面两个实例:

1
2
3
4
5
6
7
//普通类变量:在准备阶段为它开了内存空间,但是它的value是int的初始值,也就是 0!
//而真正的123赋值,是在类构造器,也就是下面的初始化阶段
public static int a = 123;

//final修饰的类变量,编译成字节码后,是一个ConstantValue类型
//这种类型,在准备阶段,直接给定值123,后期也没有二次初始化一说
public static final int b = 123;

4.4 解析

解析阶段开始解析类之间的关系,需要关联的类被加载。

这涉及到:

  • 类或接口的解析:类相关的父子继承,实现的接口都有哪些类型?
  • 字段的解析:字段对应的类型?
  • 方法的解析:方法的参数、返回值、关联了哪些类型
  • 接口方法的解析:接口上的类型?

经过解析后,当前class里的方法字段父子继承等对象级别的关系解析完成。

这些操作上相关的类信息也被加载。

4.4 初始化

4.4.1 概述

最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收。

前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。

而这个阶段,是赋值,才是我们应用程序中编写的有主导权的地方

在准备阶段,jvm已经初始化了对应的内存空间,final也有了自己的值。但是其他类变量,是在这里赋值完成的。

也就是我们说的:

1
public static int a = 123;  

这行代码的123才真正赋值完成。

4.4.2 两个初始化

1)类变量与实例变量的区分

注意一件事情!

这里所说的初始化是一个class类加载到内存的过程,所谓的初始化值得是类里定义的类变量。也就是静态变量。

这个初始化要和new一个类区分开来。new的是实例变量,是在执行阶段才创建的。

2)实例变量创建的过程

当我们在方法里写了一段代码,执行过程中,要new一个类的时候,会发生以下事情:

  • 在方法区中找到对应类型的类信息
  • 在当前方法栈帧的本地变量表中放置一个reference指针
  • 在堆中开辟一块空间,放这个对象的实例
  • 将指针指向堆里对象的地址,完工!

5、对象创建

前面我们介绍的是从class字节码文件加载到jvm内存的过程。

接下来,我们看jvm里的代码跑起来以后,在运行过程中,对象的创建和销毁在内存中经历了什么样的事情。

5.1 对象创建

5.1.1 概述

从你new一个对象开始,发生了什么?

遇到new指令,jvm首先要做的事是检查有没有这个类,没有的话,加载它!

5.1.2 内存分配

类加载检查通过后,就要给新对象分配内存。

因为一个类型确定后,它内部定义了哪些结构哪些值,所需要的内存空间也就确定了。先给他划出来。

(具体对象的内存布局,在下一小节)

但是这个内存分配,具体怎么划出来,有不同的方式:

1)指针碰撞(Bump The Pointer)

这种分配前提是内存中有整片连续的空间,用的在一边,空闲的在另一边,一个指针指向分界线。

需要多少指针往空闲那边移动多少,直接划分出来一段,给当前对象,完工。

image-20210331153504656

2)空闲列表(Free List)

那如果jvm堆不那么规整呢?用的和没用的交叉在一起,也就是我们所说的内存碎片。

这种情况就需要我们单独有一张表来记录,哪些内存块是空的。

分配的时候查表,找到大小够用的一块,分配给对象,同时更新列表。

具体哪种方式,和我们的垃圾回收器有关系。有的垃圾回收器会对内存做整理压缩,那就指针碰撞简单高效。如果没有压缩功能,那只能是采用空闲列表

这部分详细的垃圾回收在下面的第6小节(内容很多!)

3)并发性

无论指针移动还是空闲列表的同一个指针空间,在并发分配的情况下会不会有问题?

很聪明!确实有并发问题。那jvm是如何解决的呢?

方式一:cas原子操作 + 失败重试

image-20210331160410760

方式二:本地线程分配缓冲(TLAB)

全称是 Thread Local Allocation Buffer,需要 -XX:+/-UseTLAB

我们知道,对于栈、计数器,每个线程独享,堆是共享的。

但实际上,我们可以让线程在创建时,先独享划走一部分堆。

那么线程创建对象需要内存时,可以在自己划走的堆上先操作。相当于每个线程批发了一批内存先用着。

当前线程空间不够时,再去公共堆上申请,这样就减少了并发冲突的机会。当然也多少有点浪费

5.2 内存布局

上面我们给这个对象分配好了内存空间,那么问题来了。对象拿走的这块内存,它都写了些啥进去呢?

对象在堆上的布局,可以分为三个部分:对象头、实例数据、对齐填充。

image-20210331164644697

image-20230928164306371

5.2.1 对象头

对象头一般分为两部分,Mark Word 和 类型指针(Hotspot)

1)Mark Word,官方叫法,其实就是存储对象自己运行时的数据

如哈希码、GC分代年龄、锁状态标记、线程持有的锁、偏向的线程id……(不用记)

2)类型指针

指向当前对象的类型。也就是方法区里,类信息的地址。

当然这里不是绝对的,hotspot这么设计。在5.3对象访问一节,还会看到其他方式。

5.2.2 实例数据

对象里各个字段的值。这个好理解。

long,double,int等长度都是固定的

string、对象类型等是个地址,指向其他外部堆空间

5.2.3 对齐填充

不是必须的。就是个占位符而已。

Hotspot规定的,内存管理系统要求对象的大小必须是8字节的整数倍。

这个在对象头上,已经被精心设计过,满足要求。

但是!实例数据部分不一定。如果没有对齐的话,通过这里的对齐填充补满它。没有别的意义。

5.3 对象的访问

对象创建了,就要用,它在堆里。

我们的程序运行时,大家知道,每一个方法相关的变量信息都在栈里。那么怎么找到这个对象呢?

一般来讲,两种方案:

5.3.1 句柄访问

image-20210331114434625

句柄方式:

栈指针指向堆里的一个句柄的地址,这个句柄再定义俩指针分别指向类型和实例

很显然,垃圾回收移动对象的话只需要改句柄即可,不会波及到栈,但是多了一次寻址操作

5.3.2 直接地址

image-20210331114645139

直接地址:

栈指针指向的就是实例本身的地址,在实例里封装一个指针指向它自己的类型

很显然,垃圾回收移动对象要改栈里的地址值,但是它减少了一次寻址操作。

备注:hostspot使用的是直接地址方式

6、对象的销毁

java语言开始学习的时候,就骄傲的告诉大家作为一个java程序员我们具有c++们羡慕不已的技能:不用关心内存是怎么收回来的。

前面讲的都是类、对象是怎么来的,这节我们讲讲它是怎么没的。

6.1 jvm参数

在学垃圾回收器之前,我们先要知道,jvm参数是怎么回事。因为配置各种回收器,必须对应各种参数设置。

在jvm中有很多的参数可以进行设置,绝大部分的参数保持默认即可。

6.1.1 分类

jvm的参数类型看上去杂乱,其实一共就三类,分别是:

  • 标准参数(-)

    所有的JVM实现都必须实现这些参数的功能,而且向后兼容

    • -help
    • -version
  • 非标准参数(-X)

    默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容

    • -Xint
    • -Xcomp
  • 非Stable参数(-XX)

    各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用

    • -XX:newSize
    • -XX:+UseSerialGC

在使用X或XX参数的时候,一定要先知道自己的jdk版本,并查对应的手册确认。

6.1.2 标准参数

1)概述

jvm的标准参数,一般都是很稳定的,在未来的JVM版本中不会改变。

使用java -help列出来的就是标准参数。

1
2
3
4
5
6
7
8
#打印帮助信息
shawn@macpro:~ > java -help
用法:java [options] <主类> [args...]
(执行类)
或 java [options] -jar <jar 文件> [args...]
(执行 jar 文件)
……
此处省略n行

图片效果:

image-20220830175025468

2)实例一

我们最熟悉的jdk版本查看 java -version

1
2
3
4
shawn@macpro:~ > java -version
java version "11.0.2" 2019-01-15 LTS #版本号
Java(TM) SE Runtime Environment 18.9 (build 11.0.2+9-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.2+9-LTS, mixed mode) #运行模式:server

附:关于运行模式(了解)

可以通过-server-client设置JVM的运行参数

  • Server VM的初始堆空间会大一些,默认使用的是并行垃圾回收期,启动慢运行快。
  • Client VM相对来讲会保守一些,初始堆空间会小一些,使用的是串行垃圾回收期,启动快运行慢。
  • JVM在启动的时候会根据硬件和操作系统自动选择使用Server还是Client类型的JVM。
  • 32位操作系统
    • 如果是Windows系统,不论硬件配置如何,都默认使用Client类型的JVM。
    • 如果是其他操作系统上,2G,2核以上默认使用server模式,否则使用client模式。
  • 64位操作系统
    • 只有server类型,不支持client类型。

image-20220830180020170

ps: -showversion展示版本

3)实例二

通过-D设置系统属性参数

1
2
3
4
5
6
7
8
9
public class TestJVM {

public static void main(String[] args) {
//获取命令行 -D设置的参数
String str = System.getProperty("str");
System.out.println(str);
}
}

image-20220830175226283

将会输出nbchen

image-20220830175317648

6.1.3 -X参数

1)概述

jvm的-X参数是非标准参数,也就意味着,在不同版本的jvm中,参数可能会有所不同

可以通过java -X查看非标准参数

这部分参数非常多,知道常用的几个即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[root@node01 test]# java -X
-Xmixed 混合模式执行 (默认) #了解!
-Xint 仅解释模式执行 #了解!
-Xbootclasspath:<用 : 分隔的目录和 zip/jar 文件>
设置搜索路径以引导类和资源
-Xbootclasspath/a:<用 : 分隔的目录和 zip/jar 文件>
附加在引导类路径末尾
-Xbootclasspath/p:<用 : 分隔的目录和 zip/jar 文件>
置于引导类路径之前
-Xdiag 显示附加诊断消息
-Xnoclassgc 禁用类垃圾收集
-Xincgc 启用增量垃圾收集
-Xloggc:<file> 将 GC 状态记录在文件中 (带时间戳)
-Xbatch 禁用后台编译
-Xms<size> 设置初始 Java 堆大小 #掌握!
-Xmx<size> 设置最大 Java 堆大小 #掌握!
-Xss<size> 设置 Java 线程堆栈大小 #掌握!
-Xprof 输出 cpu 配置文件数据
-Xfuture 启用最严格的检查, 预期将来的默认值
-Xrs 减少 Java/VM 对操作系统信号的使用 (请参阅文档)
-Xcheck:jni 对 JNI 函数执行其他检查
-Xshare:off 不尝试使用共享类数据
-Xshare:auto 在可能的情况下使用共享类数据 (默认)
-Xshare:on 要求使用共享类数据, 否则将失败。
-XshowSettings 显示所有设置并继续
-XshowSettings:all
显示所有设置并继续
-XshowSettings:vm 显示所有与 vm 相关的设置并继续
-XshowSettings:properties
显示所有属性设置并继续
-XshowSettings:locale
显示所有与区域设置相关的设置并继续

-X 选项是非标准选项, 如有更改, 恕不另行通知。

图片效果:

image-20220830180208969

2)案例一:-Xint-Xcomp-Xmixed

  • 在解释模式(interpreted mode)下,-Xint标记会强制JVM执行所有的字节码,当然这会降低运行速度,通常低10倍或更多。

  • -Xcomp参数与它(-Xint)正好相反,JVM在第一次使用时会把所有的字节码编译成本地代码,从而带来最大程度的优化。

    • 然而,很多应用在使用-Xcomp也会有一些性能损失,当然这比使用-Xint损失的少,原因是-xcomp没有让JVM启用JIT编译器的全部功能。JIT编译器可以对是否需要编译做判断,如果所有代码都进行编译的话,对于一些只执行一次的代码就没有意义了。
  • -Xmixed是混合模式,将解释模式与编译模式进行混合使用,由jvm自己决定,这是jvm默认的模式,也是推荐使用的模式。

示例:强制设置运行模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#强制设置为解释模式
shawn@macpro:~ > java -Xint -version
java version "11.0.2" 2019-01-15 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.2+9-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.2+9-LTS, interpreted mode)

#强制设置为编译模式
shawn@macpro:~ > java -Xcomp -version
java version "11.0.2" 2019-01-15 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.2+9-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.2+9-LTS, compiled mode)

#默认的混合模式
shawn@macpro:~ > java -Xmixed -version
java version "11.0.2" 2019-01-15 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.2+9-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.2+9-LTS, mixed mode)

图片效果:

image-20220830180550626

3)案例二:-Xms与-Xmx参数

-Xms-Xmx分别是设置jvm的堆内存的初始大小和最大大小。

  • -Xmx2048m:等价于-XX:MaxHeapSize,设置JVM最大堆内存为2048M。
  • -Xms512m:等价于-XX:InitialHeapSize,设置JVM初始堆内存为512M。

image-20220830181511968

具体案例前面设置溢出的时候配置过,不再赘述。

比如分配:-Xmx20m -Xms5m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
System.out.print("Xmx=");
System.out.println(Runtime.getRuntime().maxMemory()/1024.0/1024+"M"); // Xmx=19.375M

System.out.print("free mem=");
System.out.println(Runtime.getRuntime().freeMemory()/1024.0/1024+"M"); // free mem=4.342750549316406M

System.out.print("total mem=");
System.out.println(Runtime.getRuntime().totalMemory()/1024.0/1024+"M"); // total mem=4.875M

----------------------------------------------------------------------

byte[] b=new byte[1*1024*1024];
System.out.println("分配了1M空间给数组");

分配了1M空间给数组
Xmx=19.375M
free mem=3.4791183471679688M
total mem=4.875M

Java会尽可能维持在最小堆

----------------------------------------------------------------------

b=new byte[4*1024*1024];
System.out.println("分配了4M空间给数组");

分配了4M空间给数组
Xmx=19.375M
free mem=3.5899810791015625M
total mem=9.00390625M // 总内存变多了

----------------------------------------------------------------------
System.gc();

回收内存
Xmx=19.375M
free mem=6.354591369628906M //空闲内存增多
total mem=10.75390625M

思考:

  • -Xmx–Xms 应该保持一个什么关系,可以让系统的性能尽可能的好呢?
  • 如果你要做一个Java的桌面产品,需要绑定JRE,但是JRE又很大,你如何做一下JRE的瘦身呢?

6.1.4 -XX参数

-XX参数也是非标准参数,主要用于改变jvm的一些基础行为,比如垃圾回收行为、jvm的调优、输出debug调试信息等。

可以通过以下方式查看(非常多!):

  • java -XX:+PrintFlagsFinal : 被修改过,最终生效的配置
  • java -XX:+PrintFlagsInitial : jvm初始化的配置

-XX参数的使用有2种方式,一种是boolean类型,一种是非boolean类型:

  • boolean类型
    • 格式:-XX:[+-]<name> 表示启用或禁用<name>属性.(+表示启用,-表示禁用)
    • 如:-XX:+DisableExplicitGC 表示禁用手动调用gc操作,也就是说调用System.gc()无效
  • 非boolean类型
    • 格式:-XX:<name>=<value> 表示<name>属性的值为<value>
    • 如:-XX:NewRatio=4 表示新生代(eden+2*s)和老年代的比值为1:4,即年轻代占堆的1/5
      • 扩展:-Xmn 设置新生代大小
      • -XX:SurvivorRatio设置两个Survivor区和eden的比。8表示 两个Survivor :eden=2:8,即一个Survivor占年轻代的1/10

image-20220830181222212

这类参数的案例,下面多得是,我们到垃圾收集一节再详细讲解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#行为参数(功能开关)
-XX:-DisableExplicitGC 禁止调用System.gc();但jvm的gc仍然有效
-XX:+MaxFDLimit 最大化文件描述符的数量限制
-XX:+ScavengeBeforeFullGC 新生代GC优先于Full GC执行
-XX:+UseGCOverheadLimit 在抛出OOM之前限制jvm耗费在GC上的时间比例
-XX:-UseConcMarkSweepGC 对老生代采用并发标记交换算法进行GC
-XX:-UseParallelGC 启用并行GC
-XX:-UseParallelOldGC 对Full GC启用并行,当-XX:-UseParallelGC启用时该项自动启用
-XX:-UseSerialGC 启用串行GC
-XX:+UseThreadPriorities 启用本地线程优先级

#性能调优
-XX:LargePageSizeInBytes=4m 设置用于Java堆的大页面尺寸
-XX:MaxHeapFreeRatio=70 GC后java堆中空闲量占的最大比例
-XX:MaxNewSize=size 新生成对象能占用内存的最大值
-XX:MaxPermSize=64m 老生代对象能占用内存的最大值
-XX:MinHeapFreeRatio=40 GC后java堆中空闲量占的最小比例
-XX:NewRatio=2 新生代内存容量与老生代内存容量的比例
-XX:NewSize=2.125m 新生代对象生成时占用内存的默认值
-XX:ReservedCodeCacheSize=32m 保留代码占用的内存容量
-XX:ThreadStackSize=512 设置线程栈大小,若为0则使用系统默认值
-XX:+UseLargePages 使用大页面内存

#调试参数
-XX:-CITime 打印消耗在JIT编译的时间
-XX:ErrorFile=./hs_err_pid<pid>.log 保存错误日志或者数据到文件中
-XX:-ExtendedDTraceProbes 开启solaris特有的dtrace探针
-XX:HeapDumpPath=./java_pid<pid>.hprof 指定导出堆信息时的路径或文件名
-XX:-HeapDumpOnOutOfMemoryError 当首次遭遇OOM时导出此时堆中相关信息
-XX:OnError="<cmd args>;<cmd args>" 出现致命ERROR之后运行自定义命令
-XX:OnOutOfMemoryError="<cmd args>;<cmd args>" 当首次遭遇OOM时执行自定义命令
-XX:-PrintClassHistogram 遇到Ctrl+Break后打印类实例的柱状信息,与jmap -histo功能相同
-XX:-PrintConcurrentLocks 遇到Ctrl-Break后打印并发锁的相关信息,与jstack -l功能相同
-XX:-PrintCommandLineFlags 打印在命令行中出现过的标记
-XX:-PrintCompilation 当一个方法被编译时打印相关信息
-XX:-PrintGC 每次GC时打印相关信息,也可以用-verbose:gc
-XX:-PrintGCDetails 每次GC时打印详细信息
-XX:-PrintGCTimeStamps 打印每次GC的时间戳
-XX:-TraceClassLoading 跟踪类的加载信息
-XX:-TraceClassLoadingPreorder 跟踪被引用到的所有类的加载信息
-XX:-TraceClassResolution 跟踪常量池
-XX:-TraceClassUnloading 跟踪类的卸载信息
-XX:-TraceLoaderConstraints 跟踪类加载器约束的相关信息

6.1.5 参数查询

运行时打印参数,可以通过-XX:+PrintFlagsFinal设置

image-20220830182151723

如果想要查看正在运行的jvm就需要借助于jinfo命令查看。

1)起一个进程,让它处于运行中

1
2
3
4
5
6
7
8
public class App {
public static void main( String[] args ) throws InterruptedException {
while (true) {
System.out.println(System.currentTimeMillis());
Thread.sleep(1000);
}
}
}

也可以时启动一个tonmcat,也是Java进程

image-20220830183048287

2)jps查到他的进程号

除了ps -ef查看正再运行的Java进程,用jps也可以

1
2
3
4
5
shawn@macpro:~ > jps
44561
2691 Launcher
2692 App
2693 Jps

jps -l还可以查看详细的运行包

图片效果:

image-20220830182708026

3)查询看运行参数

1
2
3
4
5
6
7
8
shawn@macpro:~ > jinfo -flags 2692
VM Flags:
-XX:CICompilerCount=3 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MaxNewSize=715653120 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=44564480 -XX:OldSize=89653248 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseParallelGC

#查看某一参数的值,用法:jinfo -flag <参数名> <进程id>
shawn@macpro:~ > jinfo -flag MaxHeapSize 2692
-XX:MaxHeapSize=2147483648

图片效果:

image-20220830182854330

查看单个效果:

image-20220830183016790

6.2 垃圾回收概述

垃圾回收背景

实际上,垃圾回收并不是java首创的。垃圾收集的历史远比java语言本身还要久

最早使用垃圾回收功能的语言是Lisp,于1960年诞生于麻省理工学院。

Java中,GC(Garbage Collection)的对象是堆空间和永久区

什么是垃圾回收?

程序的运行必然需要申请内存资源,无效的对象资源如果不及时处理就会一直占有内存资源,最终将导致内存溢
出,所以对内存资源的管理是非常重要了。

C/C++语言的垃圾回收

在C/C++语言中,没有自动垃圾回收机制,是通过new关键字申请内存资源,通过delete关键字释放内存资源。
如果,程序员在某些位置没有写delete进行释放,那么申请的对象将一直占用内存资源,最终可能会导致内存溢
出。

Java语言的垃圾回收

为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回
收机制,也就是我们熟悉的GC。
有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。
换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同
样也可能会导致内存溢出的。
当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。

为什么要学垃圾回收/意义

jdk发展到今天垃圾回收已经相当完善,为什么还要学习它?

  • 面试必问!(无力吐槽)
  • 排查内存泄露、溢出等问题
  • 系统默认配置不适合当前业务特殊场景的时候,必须手动调整参数

6.3 回收事件三要素

语文老师告诉我们:任何事情发生一定有三要素:时间、地点、人物

6.3.1 在哪收(地点)

根据上面我们的学习,大家对jvm里的内存空间有了清晰的认识。那么:

  • 程序计数器、jvm虚拟机栈、本地方法栈,这些随着线程诞生和消亡,线程释放它就释放,无需回收。

  • 方法区,这里是一些类信息和静态变量,也有回收的可能性,但是很鸡肋,收不回多少东西。

    实际上,虚拟机规范也并不强制要求回收这里。

  • 堆,这才是大头。因为运行期频繁创建和丢弃对象的事件都在这里发生!

所以,谈回收我们主要看堆。

6.3.2 什么时候收(时间)

回收我们是不需要管的,那么必然有对应的机制,或者说什么条件下满足了,触发了jvm的内存回收。

哪些条件呢?

  • 在堆内存存储达到一定阈值之后  

    当年轻代或者老年代达到一定阈值,Java虚拟机无法再为新的对象分配内存空间了,那么Java虚拟机就会触发一次GC去回收掉那些已经不会再被使用到的对象

  • 主动调用System.gc() 后尝试进行回收

    手动调用System.gc()方法,通常这样会触发一次的Full GC,所以一般不推荐这个东西的使用,你会干扰jvm的运作

6.3.3 回收谁(人物)

回收谁?哪些对象能够被回收,哪些还不能?总得有个判断标准。

在编程语言界,有两种办法判定一个对象是否已消亡:

6.3.3.1 引用计数法

引用计数是历史最悠久的一种算法,最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用。(了解即可,因为JVM不用!COM、ActionScript3、Python使用)

1)原理

假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。

image-20220605092936434

2)优缺点

优点:

  • 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
  • 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报outofmember 错误。
  • 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。

缺点:

  • 每次对象被引用时,都需要去更新计数器,有一点时间开销。
  • 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
  • 无法解决循环引用问题。(最大的缺点)

image-20220605093119151

3)案例:什么是循环引用?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TestA{
public TestB b;
}

class TestB{
public TestA a;
}

public class Main{
public static void main(String[] args){
A a = new A();
B b = new B();
a.b=b;
b.a=a;
a = null; //释放资源
b = null; //释放资源
}
}

虽然a和b都为null,但是由于a和b存在循环引用,根据引用的理论,a和b永远都不会被回收。

事实上,以上代码我们在开发中是很可能存在的,我们的jvm也没有被撑爆。因为jvm没有采用这种算法。

那它用的啥呢?

6.3.3.2 可达性分析

1)概述

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,就说明从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,就是可以回收的对象。

JVM用的是这种算法!

image-20200429102816516

2)GC Roots清单

在JVM虚拟机中,可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 在方法区中类静态属性引用的对象(类变量)。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

3)附:对象的四类引用

在java中,对象的引用主要有4种,从上到下级别依次降低。不同的引用回收的态度不同

  • 强引用
    • 在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。
    • 无论任何情况下,内存用不回收,够就够,不够抛内存溢出异常。
  • 软引用
    • 用来描述一些还有用,但非必须的对象。被SoftReference包装的那些类
    • 先回收没用的对象,收完后发现还不够,再触发二次回收,对软引用对象下手。
  • 弱引用
    • 用来描述那些非必须对象,强度比软引用更弱。被WeakReference包装的那些类
    • 无论当前内存是否足够,垃圾收集一旦发生,弱引用直接回收。
  • 虚引用(实际开发基本不用)
    • 最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
    • 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

6.4 回收算法(策略)

回收是做一件事情,要完成这件事,我们需要采用什么样的策略?用什么样的思想会更稳妥?

这就涉及到回收的具体算法

自动化的管理内存资源,垃圾回收机制必须要有一套算法来进行计算,哪些是有效的对象,哪些是无效的对象,对于无效的对象就要进行回收处理。

常见的垃圾回收算法有:引用计数法、标记清除法、标记压缩法、复制算法、分代算法等。

引用计数法

引用计数是历史最悠久的一种算法,最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用。

原理:

假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。

优点:

  • 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
  • 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报outofmember 错误。
  • 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。

缺点:

  • 每次对象被引用时,都需要去更新计数器,有一点时间开销。
  • 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
  • 无法解决循环引用问题。(最大的缺点)

什么是循环引用?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TestA{ 
public TestB b;
}

class TestB{
public TestA a;
}

public class Main{
public static void main(String[] args){
A a = new A();
B b = new B();
a.b=b;
b.a=a;
a = null;
b = null;
}
}

虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。

标记清除法

1)概述

标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。

  • 标记:从根节点开始标记引用的对象。
  • 清除:未被标记引用的对象就是垃圾对象,清理掉。

标记清除法可以说是最基础的收集算法,因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。

2)原理/执行过程

image-20220915201654499

这张图代表的是程序运行期间所有对象的状态,它们的标志位全部是0(也就是未标记,以下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,JVM将会停止应用程序的运行并开启GC线程,然后开始进行标记工作,按照根搜索算法,标记完以后,对象的状态如下图。

image-20220915201753250

可以看到,按照根搜索算法,所有从root对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。接下来,就要执行第二阶段清除了,那么清除完以后,剩下的对象以及对象的状态如下图所示。

image-20220915201850728

可以看到,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归0。接下来就不用说了,唤醒停止的程序线程,让程序继续运行即可。

3)优点:

  • 可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。

4)缺点:

  • 执行效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。
  • 通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。

image-20200429235159875

程序向JVM申请内存的时候,可能会认为这些”碎片化”的内存空间不足,不够用。而去别的地方申请内存空间。

5)为什么要暂停?

因为标记要遍历所有对象,如果不断有新对象加入,没法确定标记关系,可能会把存活的对象也清除掉,所以必须要暂停。

标记压缩算法

1)概述

也叫标记-整理,标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。

和标记清除算法一样,也是从根节点开始,对对象的引用进行标记

在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题。

2)执行过程: image-20200429235422031

3)特点

  • 优点:该算法解决了标记清除算法的碎片化的问题,下一步分配内存的时候更方便
  • 缺点:多了一步整理操作,对象需要移动内存位置,效率也好不到哪去。

标记复制算法

1)概述

复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。

如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

2)过程

image-20200430001554975

3)附:年轻代的标记复制算法

JVM中年轻代内存空间的回收就是典型的标记复制法

image-20200430001725601

  • sruvivor区有两个,一个from,另一个叫to,这俩交替互换角色
  • 1.在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。
  • 2.紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置) 的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。
  • 3.经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。
  • 4.GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

4)优缺点

优点:

  • 在垃圾对象多的情况下,效率较高,因为要把存活的全部移动一遍
  • 清理后,内存无碎片

缺点:

  • 在垃圾对象比例少的情况下,不适用,如:年轻代这么用可以,老年代就不合适
  • 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低

分代算法

前面介绍了多种回收算法,每一种算法都有自己的优点也有缺点,谁都不能替代谁,所以根据垃圾回收对象的特点进行选择,才是明智的选择。

1)具体思想

确切的说,分代不算是一种算法,它是一种解决回收问题的思路:具体情况具体分析

在堆内存中,有些对象短暂存活有些则是长久存活,所以需要将堆内存进行分代,将短暂存活的对象放到一起,进行高频率的回收,长久存活的对象集中放到一起,进行低频率的回收

细粒度的控制不同区域,调节不同的回收频率,节约系统资源(回收期间系统要额外干活的!)。

分代算法其实就是这样的,根据回收对象的特点进行选择,在jvm中,年轻代(少量对象存活)适合使用复制算法,老年代(大量对象存活)适合使用标记清除或标记压缩算法。

2)相关概念

  • 部分收集(Partial GC)
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。(CMS收集器)
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。(G1收集器)
  • 整堆收集(Full GC)
    • 所有的内存整理一遍,包括堆和方法区。轻易不要触发

6.5 回收器(执行者)

前面我们讲了垃圾回收的算法,还需要有具体的实现。

策略有了,谁来执行呢?这事就落到任劳任怨的收集器头上了

在jvm中,实现了多种垃圾收集器,这些收集器种类繁多,看似乱七八糟,其实理清楚后很简单。

包括:串行垃圾收集器、并行垃圾收集器、CMS(并发)垃圾收集器、G1垃圾收集器,接下来,我们一个个的了解学习。

1)先明白几件事情

  • 用户线程:java程序运行后,用户不停请求操作jvm内存,这些称为用户线程

  • GC线程:jvm系统进行垃圾回收启动的线程

  • 串行:GC采用单线程,收集时停掉用户线程

  • 并行:GC采用多线程,收集时同样要停掉用户线程

  • 并发:用户线程和GC线程同步进行,这意义就不一样了

  • STW:stop the world ,暂停响应用户线程,只提供给GC线程工作来回收垃圾(很不爽的事情)

    • 危害:长时间服务停止,没有响应遇到。HA系统,可能引起主备切换,严重危害生产环境。
  • 分代:垃圾收集器是要工作在某个代上的,可能是年轻代,老年代,有的可能两个代都能工作

  • 组合:因为分代,所以得有组合,你懂得……

2)准备案例

在开始前,我们先准备一个内存堆积的案例,下面学习收集器,只需要在启动时指定不同的XX参数即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package cn.itcast.jvm;

import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.Random;

public class TestGC {

public static void main(String[] args) throws Exception {
List<Object> list = new ArrayList<Object>();
//模拟web中不停请求,不断地 产生新的数据(对象),随机的废弃对象(垃圾)
while (true){
int sleep = new Random().nextInt(100);

if(System.currentTimeMillis() % 2 ==0){
//模拟释放,如果恰好请求时间是偶数,清空列表
list.clear();
}else{
//模拟业务,从db中查询了10000条记录
for (int i = 0; i < 10000; i++) {
Properties properties = new Properties();
properties.put("key_"+i, "value_" + System.currentTimeMillis() + i);
list.add(properties);
}
}

System.out.println("list大小为:" + list.size());

//模拟请求间隔,0-100ms随机
Thread.sleep(sleep);
}
}
}

6.5.1 串行

串行垃圾收集器,是指使用单线程进行垃圾回收,垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)。

对于交互性较强的应用而言,这种垃圾收集器是不能够接受的。

一般在Javaweb应用中是不会采用该收集器的

1)概述

其实是两个收集器,年轻代的叫 Serial , 老年代的叫 Serial Old,很好记!

这是最基础的,历史最悠久的收集器。

听名字就知道,这个属于串行收集器,即:GC时,停掉用户线程,同时,GC本身也是只有一个线程在跑

2)原理

很简单,GC时暂停用户进程,新生代 Serial 采用复制算法,Serial Old采用标记整理算法。

image-20210406165830911

3)优缺点

单线程 + STW,那么这个收集器还有存在的价值吗?

答案是:有!我们在吐槽单线程的同时,不要忘了,单线程带来的便捷性

实际上,Serial收集器依然是hotspot在客户端模式下的默认收集器,因为它足够简单有效,没有多线程GC的协调和额外开销,在单核或资源有限的环境下,单线程甚至比多线程还要高效。

而Serial Old则作为下面几款垃圾收集器的兜底措施,比如CMS、G1等处理不了老年代时,他们会自动启用SOld来做FullGC进行收集。

4)配置参数

在程序运行参数中添加2个参数,如下:

  • -XX:+UseSerialGC:指定年轻代和老年代都使用串行垃圾收集器,(新生代复制算法,老年代标记-压缩)
  • -XX:+PrintGCDetails:打印垃圾回收的详细信息

5)操作案例

1
2
3
# 为了测试GC,将堆的初始和最大内存都设置为16M
# java代码使用本节开头的例子
-XX:+UseSerialGC -XX:+PrintGCDetails -Xms16m -Xmx16m

image-20210406170434507

6)启动程序,可以看到下面信息:

1
2
3
[GC (Allocation Failure) [DefNew: 4416K->512K(4928K), 0.0046102 secs] 4416K->1973K(15872K), 0.0046533 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

[Full GC (Allocation Failure) [Tenured: 10944K->3107K(10944K), 0.0085637 secs] 15871K->3107K(15872K), [Metaspace: 3496K->3496K(1056768K)], 0.0085974 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

GC日志信息解读:

年轻代的内存GC前后的大小:

  • DefNew:表示使用的是串行垃圾收集器。
  • 4416K->512K (4928K):表示,年轻代GC前,占有4416K内存,GC后,占有512K内存,总大小4928K
  • 0.0046102 secs:表示,GC所用的时间,单位为秒。
  • 4416K->1973K (15872K):表示,GC前,堆内存占有4416K,GC后,占有1973K,总大小为15872K
  • Full GC:表示,内存空间全部进行GC,老年代、元空间

6.5.2 并行

并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。(这里是指,并行能力较强的机器)

当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些

1)概述

  • ParNew收集器:

    新生代的,无非就是将Serial的单线程换成多线程,它现在存在的唯一价值就是作为新生代收集器配合老年代的CMS收集器一起工作,并且在jdk9里也已不再推荐这套组合,而是推荐G1

    通过-XX:+UseParNewGC参数设置年轻代使用ParNew回收器,老年代使用的依然是串行收集器。

image-20220915204744944

1
2
3
4
5
6
#参数 
-XX:+UseParNewGC -XX:+PrintGCDetails -Xms16m -Xmx16m

#打印出的信息
[GC (Allocation Failure) [ParNew: 4416K->512K(4928K), 0.0032106 secs] 4416K-
>1988K(15872K), 0.0032697 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

由以上信息可以看出, ParNew: 使用的是ParNew收集器。其他信息和串行收集器一致。

我们只需要知道的是:曾经,它存在过。

  • 另外一对并行收集器:

    Parallel Scavenge (新生代的) / Parallel Old (老年代的)

关于并行收集器,我们重点看Parallel这一对,这一对也是主流的jdk8下默认收集器

2)详解

image-20210406173628609

Parallel这一对,它所关注的是系统的吞吐量。所谓吞吐量,反映的是用户线程在系统整体时间里可用的比例:

即: 吞吐量 = 用户代码运行时间 / ( 用户代码运行时间 + 垃圾收集器运行时间 )

这一点,从它的配置参数上直接就能看出来

3)参数

ParallelGC收集器工作机制和ParNewGC收集器一样,只是在此基础之上,新增了两个和系统吞吐量相关的参数,使得其使用起来更加的灵活和高效。

  • -XX:+UseParallelGC: 年轻代使用ParallelGC垃圾回收器,老年代使用串行回收器。
  • -XX:+UseParallelOldGC: 年轻代使用ParallelGC(并行)垃圾回收器,老年代使用ParallelOldGC(并行)垃圾回收器。
  • -XX:MaxGCPauseMillis
    • 设置最大的垃圾收集时的停顿时间,单位为毫秒
    • 需要注意,ParallelGC为了达到设置的停顿时间,可能会调整堆大小或其他的参数,如果堆的大小设置的较小,就会导致GC工作变得很频繁,反而可能会影响到性能。
    • 该参数使用需谨慎。
  • -XX:GCTimeRatio
    • 直接设置垃圾回收时间占程序运行时间的最大百分比,公式为1/(1+n)。
    • 它的值为0~100之间的数字,默认值为99,也就是垃圾回收时间不能超过1%
    • 该参数了解,一般不设置
  • -XX:UseAdaptiveSizePolicy
    • 自适应GC模式,垃圾回收器将自动调整年轻代、老年代等参数,达到吞吐量、堆大小、停顿时间之间的平衡。
    • 一般用于,手动调整参数比较困难的场景,让收集器自动进行调整。
    • 也不常用

3)调试

1
2
#参数
-XX:+UseParallelGC -XX:+UseParallelOldGC -XX:MaxGCPauseMillis=100 -XX:+PrintGCDetails -Xms16m -Xmx16m

image-20210406174157937

1
2
3
4
#打印的信息
[GC (Allocation Failure) [PSYoungGen: 4096K->480K(4608K)] 4096K->1840K(15872K), 0.0034307 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

[Full GC (Ergonomics) [PSYoungGen: 505K->0K(4608K)] [ParOldGen: 10332K->10751K(11264K)] 10837K->10751K(15872K), [Metaspace: 3491K->3491K(1056768K)], 0.0793622 secs] [Times: user=0.13 sys=0.00, real=0.08 secs]

PSYoungGen:年轻代,Parallel Scavenge

ParOldGen:老年代,Parallel Old

有以上信息可以看出,年轻代和老年代都使用了ParallelGC垃圾回收器。

6.5.3 并发 - CMS

CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,通过参数-XX:+UseConcMarkSweepGC进行设置。

1)简介

CMS收集器,工作在老年代。

前面的收集器都是要停止用户线程的,而CMS收集器这是真正意义上的并行处理器,也就是用户线程和GC线程在同一时间一起工作。

2)CMS垃圾回收器的执行过程如下:

image-20210407085100468

image-20220915205359020

  • 初始化标记(CMS-initial-mark) :标记root直接关联的对象,会导致stw,但是这个没多少对象,时间短
  • 并发标记(CMS-concurrent-mark):沿着上一步的root,往下追踪,这步耗时最长,但是与用户线程同时运行
  • 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  • 重新标记(CMS-remark) :因为上一步是并发进行的,所以再增量过一遍有变化的,会导致stw,但比上一步少很多
  • 并发清除(CMS-concurrent-sweep):标记完的干掉,因为是标记-清除算法,不需要移动存活对象,所以这一步与用户线程同时运行
  • 调整堆大小,设置CMS在清理之后进行内存压缩,目的是清理内存中的碎片;
  • 重置线程:重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行

3)测试

1
2
#设置启动参数
-XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -Xms16m -Xmx16m

image-20210407084211962

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#运行日志
#注意,cms默认搭配的新生代是 parnew :
[GC (Allocation Failure) [ParNew: 4926K->512K(4928K), 0.0041843 secs] 9424K->6736K(15872K), 0.0042168 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

#老年代开始:
#第一步,初始标记
[GC (CMS Initial Mark) [1 CMS-initial-mark: 6224K(10944K)] 6824K(15872K), 0.0004209 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第二步,并发标记
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第三步,预处理
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第四步,重新标记
[GC (CMS Final Remark) [YG occupancy: 1657 K (4928 K)][Rescan (parallel) , 0.0005811 secs][weak refs processing, 0.0000136 secs][class unloading, 0.0003671 secs][scrub symbol table, 0.0006813 secs][scrub string table, 0.0001216 secs][1 CMS-remark: 6224K(10944K)] 7881K(15872K), 0.0018324 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第五步,并发清理
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.004/0.004 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第六步,重置
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

由以上日志信息,可以看出CMS执行的过程。

4)优缺点

优点:

  • 不可否认,一款优秀的收集器,并发收集,低停顿。
  • 互联网服务器上低停顿的现实要求很吻合,一个网站总不能告诉用户你用10分钟,歇会再来用。

但是,CMS也不是完美的:

  • 它不能等到内存吃紧了才启动收集。因为收集期间用户线程还在跑,得预留。
  • 浮动垃圾干不掉,在并发标记、并发清理时,产生的新垃圾必须到下一次收集时处理。
  • 标记-清除算法,免不了产生碎片,可以开启压缩但这些参数在jdk9里也已废弃掉
  • 最后,搭配CMS的年轻代现在只剩下了ParNew,是那么的苍白无力。实际上,jdk9开始已经把它逐步淘汰

那么替代它的是谁呢?G1出场……

6.5.4 并发 - G1

1)概述

为解决CMS算法产生空间碎片和其它一系列的问题缺陷,G1(Garbage First)算法,在JDK 7u4版本被正式推出

oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。

JDK9默认G1为垃圾收集器的提案:https://openjdk.java.net/jeps/248

将CMS标记为丢弃的提案:https://openjdk.java.net/jeps/291

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:

  1. 第一步,开启G1垃圾收集器
  2. 第二步,设置堆的最大内存
  3. 第三步,设置最大的停顿时间

G1中提供了三种模式垃圾回收模式,Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。

2)原理

G1垃圾收集器相对比其他收集器而言,最大的区别在于它取消了年轻代、老年代的物理划分,取而代之的是将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的年轻代、老年代区域。

这样做的好处就是,我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

image-20220915205958150

在G1划分的区域中,年轻代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。

这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。

image-20210402154906657

G1打破了之前的传统观念,它依然把内存划分为eden、survivor、old,同时多了一个humongous(巨大的)区来存巨型对象。

  • 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。
  • 这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。
  • 为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

但是,这些区在物理地址上不再连续。而是把整个物理地址分成一个个大小相等的region,每一个region可以是上面角色中的一个,还可以在某个时刻转变角色,从eden变成old !(就是个标签)

这样收集的时候,它收集某些性价比高的region回收就可以了。所以某个时刻,G1可能连老带少一起收拾。

这是一个划时代的改变!

那它是怎么做的呢?收拾哪些区块呢?

image-20210402160801656

先看两个概念,容易搞混:

  • Remembered Set:记忆集,简称RS,每个 Region关联一个。RS 比较复杂,简单来说就是记录Region之间对象的引用关系。

  • Collection Set:简称CSet,在一次收集中,那些性价比高的Region揪出来组成一个回收集,将来一口气回收掉。这个集合里是筛选出来的一些Region

    至于Region里面剩下的存活的对象,多个Region压缩到一个空闲Region里去,这样就完成了一次收集。

Remembered Set(已记忆集合)

在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢?

根对象可能是在年轻代中,也可以在老年代中,那么老年代中的所有对象都是根么?

如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。

于是,G1引进了RSet的概念。它的全称是Remembered Set,其作用是跟踪指向某个堆内的对象引用。

image-20220915210947125

每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。

这样就不用全部扫描,在Rset就可以找到谁引用改了我

3)模式

G1中提供了三种模式垃圾回收模式,Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。

所谓的模式,其实也就是G1收集的时候,Region选哪种,是只选年轻代的Region?还是两种都筛选?

Young GC

选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。

参数 含义
-XX:MaxGCPauseMillis 设置G1收集过程目标时间,默认值200ms
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%

Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。

1
2
3
Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。
Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。
最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

Mixed GC

选定所有年轻代里的Region,外加统计的在用户指定的开销目标范围内选择收益高的老年代Region。

参数 含义
-XX:InitiatingHeapOccupancyPercent 当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc.

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC 并不是 Full GC。

MixedGC什么时候触发? 由参数 -XX:InitiatingHeapOccupancyPercent=n 决定。默认:45%,该参数的意思是:当老年代大小占整个堆大小百分比达到该阀值时触发。

它的GC步骤分2步:

  1. 全局并发标记(global concurrent marking)
  • 初始标记(initial mark,STW):标记从根节点直接可达的对象,这个阶段会执行一次年轻代GC,会产生全局停顿。
  • 根区域扫描(root region scan):
    • G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。
    • 该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。
  • 并发标记(Concurrent Marking):G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断。
  • 重新标记(Remark,STW):该阶段是 STW 回收,因为程序在运行,针对上一次的标记进行修正。
  • 清除垃圾(Cleanup,STW):清点和重置标记状态,该阶段会STW,这个阶段并不会实际上去做垃圾的收集,等待evacuation阶段来回收。
  1. 拷贝存活对象(evacuation):Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。

full GC

严格意义上讲,这不属于G1的模式。但是使用G1时是有可能发生的。

当mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会改为使用serial old GC(full GC)来收集整个堆。

4)运行过程

image-20230928164203175

  • 初始标记:标记出 GC Roots 直接关联的对象,这个阶段速度较快,STW,单线程执行。

  • 并发标记:从 GC Root 开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。

  • 重新标记:修正在并发标记阶段因用户程序执行而产生变动的标记记录。STW,并发执行。

  • 筛选回收:筛选回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,筛出CSet后移动合并存活对象到空Region,清除旧的,完工。因为这个阶段需要移动对象内存地址,所以必须STW。

    思考一下,这属于什么算法呢???

    答:从Region的动作来看G1使用的是标记-复制算法。而在全局视角上,类似标记 - 整理

总结:

G1前面的几步和CMS差不多,只有在最后一步,CMS是标记清除,G1需要合并Region属于标记整理

5)优缺点

  • 并发性:继承了CMS的优点,可以与用户线程并发执行。当然只是在并发标记阶段。其他还是需要STW
  • 分代GC:G1依然是一个分代回收器,但是和之前的各类回收器不同,它同时兼顾年轻代和老年代。而其他回收器,或者工作在年轻代,或者工作在老年代;
  • 空间整理:G1在回收过程中,会进行适当的对象移动,不像CMS只是简单地标记清理对象。在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速度。
  • 可预见性:为了缩短停顿时间,G1建立可预存停顿的模型,这样在用户设置的停顿时间范围内,G1会选择适当的区域进行收集,确保停顿时间不超过用户指定时间。

6)建议

  • 如果应用程序追求低停顿,可以尝试选择G1;
  • 经验值上,小内存6G以内,CMS优于G1,超过8G,尽量选择G1
  • 是否代替CMS只有需要实际场景测试才知道。(如果使用G1后发现性能还不如CMS,那么还是选择CMS)

7)附:配置参数清单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
=======G1 让垃圾回收配置简单很多,只需要打开并指定你预计的时间要求即可=======

指定使用G1收集器:
"-XX:+UseG1GC"

为G1设置暂停时间目标(JVM会尽力实现,但不保证达到),默认值为200毫秒;这个值不是越小越好。
太小的话会造成可供收集的Region数量偏少,跟不上对象产生的速度,反而会频繁触发GC降低吞吐量
G1会根据这个目标决定收集行为:
"-XX:MaxGCPauseMillis"


=======附:其他参数,一般采用默认即可=======

设置每个Region大小,值是 2 的幂,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region,默认是堆内存的1/2000。
"-XX:G1HeapRegionSize"

新生代最小值,默认值5%:
"-XX:G1NewSizePercent"

新生代最大值,默认值60%:
"-XX:G1MaxNewSizePercent"

设置STW期间,并行GC线程数n,将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8
"-XX:ParallelGCThreads"

设置并发标记阶段,并行执行的线程数n,将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。
"-XX:ConcGCThreads"

当整个Java堆的占用率达到参数值时,开始触发mix gc;默认为45:
"-XX:InitiatingHeapOccupancyPercent"

8)操作案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+PrintGCDetails -Xmx256m

#G1的日志不像CMS是严格按照事件顺序来的。
#属于分类统计,包含子操作

#总停顿时间
[GC pause (G1 Evacuation Pause) (young), 0.0044882 secs]
#并发处理耗时,线程数
[Parallel Time: 3.7 ms, GC Workers: 3]
#各个子项耗时情况……
[GC Worker Start (ms): Min: 14763.7, Avg: 14763.8, Max: 14763.8, Diff: 0.1]
#扫描根节点
[Ext Root Scanning (ms): Min: 0.2, Avg: 0.3, Max: 0.3, Diff: 0.1, Sum: 0.8]
#更新RS区域所消耗的时间
[Update RS (ms): Min: 1.8, Avg: 1.9, Max: 1.9, Diff: 0.2, Sum: 5.6]
[Processed Buffers: Min: 1, Avg: 1.7, Max: 3, Diff: 2, Sum: 5]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
#对象拷贝
[Object Copy (ms): Min: 1.1, Avg: 1.2, Max: 1.3, Diff: 0.2, Sum: 3.6]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.2]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 3]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[GC Worker Total (ms): Min: 3.4, Avg: 3.4, Max: 3.5, Diff: 0.1, Sum: 10.3]
[GC Worker End (ms): Min: 14767.2, Avg: 14767.2, Max: 14767.3, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.0 ms] #清空CardTable
[Other: 0.7 ms]
[Choose CSet: 0.0 ms] #选取CSet
[Ref Proc: 0.5 ms] #弱引用、软引用的处理耗时
[Ref Enq: 0.0 ms] #弱引用、软引用的入队耗时
[Redirty Cards: 0.0 ms]
[Humongous Register: 0.0 ms] #大对象区域注册耗时
[Humongous Reclaim: 0.0 ms] #大对象区域回收耗时
[Free CSet: 0.0 ms]
#重点:总的各个区的收集情况 收集前使用空间(总空间) -> 收集后使用空间(总空间)
[Eden: 7168.0K(7168.0K)->0.0B(13.0M) Survivors: 2048.0K->2048.0K Heap: 55.5M(192.0M)->48.5M(192.0M)] #年轻代的大小统计
[Times: user=0.00 sys=0.00, real=0.00 secs]

9)优化建议

对于G垃圾收集器优化建议

  • 年轻代大小

    • 避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。

    • 固定年轻代的大小会覆盖暂停时间目标。

  • 暂停时间目标不要太过严苛

    • G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。

    • 评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意承受更多的垃圾回收开销,而这会直接影响到吞吐量。

6.5.5 并发 - ZGC(了解)

1)概述

ZGC是一款在JDK 11中新加入的低延迟垃圾收集器,是由Oracle公司研发的。(注意!还在试验阶段)

ZGC的目标是希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的低延迟。

还在试验阶段,了解原理即可,投产的话还需等待观察。

2)设计

image-20230928164148445

ZGC的内存布局与G1一样,也采用基于Region的堆内存布局,但不同的是,ZGC的Page(ZGC中称之为页面,道理和Region一样)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Page可以具有大、中、小三类容量:

  • 小型页面(Small Page):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型页面(Medium Page):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型页面(Large Page):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。
    • 每个大页面中只会存放一个大对象,这也预示着虽然名字叫作“大型Page”,但它的实际容量完全有可能小于中型Page,最小容量可低至4MB。
    • 大型Page在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作)的,因为复制一个大对象的代价非常高昂。

3)染色指针

ZGC打上了内存指针的主意。我们来看看它在指针上做了什么

  • 在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。
  • 实际上用不了这么多,操作系统也不让你用全部的。
  • 64位的Linux可用46位(64TB)的物理地址空间,64位的Windows系统只支持44位(16TB)的物理地址空间。

我们以linux为例,即便是这64TB内存,现实中依然用不了这么多。ZGC就盯上了这块。

image-20210407153159222

它把系统允许它使用的46位,拿出来4位,来记录这个内存地址的一些额外信息,剩下的42位用于真正的寻址操作。

这也造成了一个问题,就是ZGC可以管理的内存空间不可以超过4TB(2的42次幂),但是!这依然是够用的

那么我们来看一下,它拿走4位,都做了什么?

  • 简单来说,所谓染色就是用这4位来标记当前对象有没有被移动过。
  • 如果对象从一个Page移动到了另一个,如果没有染色标记,比如G1做垃圾收集时,那就必须停掉用户进程,修改所有指向自己的引用。
  • 而有了染色标记,只需要在指向自己的引用上标注一下,移动就可以了。同时,将新旧地址记录到一张表中,叫转发表
  • 那么再有用户程序访问到这个引用时,发现已改动,到转发表查新的地址,去新地址访问即可。

这么操作有什么意义呢?

  • 他把对象迁移,和与他关联的地址修改做了解耦。
  • 这就省去了大量同步修改地址值的操作(这个操作需要STW)。
  • 一个Page里如果对象都被迁移过,那么无需挨个修改所有的指针值就可以回收掉这个Page。

关于ZGC的详细内容非常多,足够讲完一本书,想深入了解的同学参考以下拓展资料:

  • 资料文件夹: ZGC-染色指针.pdf (Oracle官网)
  • 一本参考书:《新一代垃圾回收器ZGC设计与实现》

4)工作过程

image-20200506191455510

ZGC的运作过程大致可划分为四个大的阶段,这四个阶段都是可以并发执行的。仅在Mark Start、Initial Mark 阶段中会存在短暂的STW。

  • 并发标记(Concurrent Mark)
    • 与G1一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过初始标记、最终标记的短暂停顿。
    • ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  • 并发预备重分配(Concurrent Prepare for Relocate)
    • 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成:重分配集(Relocation Set)。
    • ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
    • ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放。
  • 并发重分配(Concurrent Relocate)
    • 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
    • 由于使用了染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。
  • 并发重映射(Concurrent Remap)
    • 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。
    • 并发重映射并不是一个必须要“迫切”去完成的任务,但是当所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

5)调试

注意:ZGC低版本jdk是无法开启和使用的:

linux64 : jdk11 +

windows & mac :jdk 14+

配置启动参数

1
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx256m -Xlog:gc*=info

image-20210407180815914

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#执行日志
[1.141s][info][gc,start ] GC(2) Garbage Collection (Warmup)
[1.141s][info][gc,phases ] GC(2) Pause Mark Start 0.190ms
[1.143s][info][gc,phases ] GC(2) Concurrent Mark 1.837ms #并发标记
[1.143s][info][gc,phases ] GC(2) Pause Mark End 0.136ms
[1.144s][info][gc,phases ] GC(2) Concurrent Process Non-Strong References 0.308ms
[1.144s][info][gc,phases ] GC(2) Concurrent Reset Relocation Set 0.001ms
[1.144s][info][gc,phases ] GC(2) Concurrent Destroy Detached Pages 0.000ms
[1.145s][info][gc,phases ] GC(2) Concurrent Select Relocation Set 1.219ms #重分配集
[1.145s][info][gc,phases ] GC(2) Concurrent Prepare Relocation Set 0.009ms #预备重分配
[1.145s][info][gc,phases ] GC(2) Pause Relocate Start 0.230ms
[1.146s][info][gc,phases ] GC(2) Concurrent Relocate 0.853ms #并发重分配
[1.146s][info][gc,load ] GC(2) Load: 0.00/0.02/0.05
[1.146s][info][gc,mmu ] GC(2) MMU: 2ms/78.1%, 5ms/88.9%, 10ms/93.4%, 20ms/96.7%, 50ms/98.7%, 100ms/99.0%
[1.146s][info][gc,marking ] GC(2) Mark: 1 stripe(s), 1 proactive flush(es), 1 terminate flush(es), 0 completion(s), 0 continuation(s)
[1.146s][info][gc,reloc ] GC(2) Relocation: Successful, 1M relocated #重分配完成
[1.146s][info][gc,nmethod ] GC(2) NMethods: 59 registered, 0 unregistered
#释放情况,元空间、各种引用、堆……
[1.146s][info][gc,metaspace] GC(2) Metaspace: 4M used, 4M capacity, 5M committed, 8M reserved
[1.146s][info][gc,ref ] GC(2) Soft: 131 encountered, 0 discovered, 0 enqueued
[1.146s][info][gc,ref ] GC(2) Weak: 222 encountered, 215 discovered, 0 enqueued
[1.146s][info][gc,ref ] GC(2) Final: 0 encountered, 0 discovered, 0 enqueued
[1.146s][info][gc,ref ] GC(2) Phantom: 1 encountered, 1 discovered, 0 enqueued
[1.146s][info][gc,heap ] GC(2) Mark Start Mark End Relocate Start Relocate End High Low
[1.146s][info][gc,heap ] GC(2) Capacity: 114M (45%) 114M (45%) 114M (45%) 114M (45%) 114M (45%) 114M (45%)
[1.146s][info][gc,heap ] GC(2) Reserve: 36M (14%) 36M (14%) 36M (14%) 36M (14%) 36M (14%) 36M (14%)
[1.146s][info][gc,heap ] GC(2) Free: 142M (55%) 142M (55%) 184M (72%) 184M (72%) 184M (72%) 142M (55%)
[1.146s][info][gc,heap ] GC(2) Used: 78M (30%) 78M (30%) 36M (14%) 36M (14%) 78M (30%) 36M (14%)
[1.146s][info][gc,heap ] GC(2) Live: - 1M (1%) 1M (1%) 1M (1%) - -
[1.146s][info][gc,heap ] GC(2) Allocated: - 0M (0%) 0M (0%) 4M (2%) - -
[1.146s][info][gc,heap ] GC(2) Garbage: - 76M (30%) 34M (14%) 34M (14%) - -
[1.146s][info][gc,heap ] GC(2) Reclaimed: - - 42M (16%) 42M (16%) - -
[1.146s][info][gc ] GC(2) Garbage Collection (Warmup) 78M(30%)->36M(14%)

6.5.6 归纳总结

1)我们先把所学的收集器做个汇总:

名称 算法 工作区域 线程 模式 适用场合 优缺点
Serial 复制 新生 串行 单CPU;Client模式下 缺:stop the world;优:简单高效,没有线程交互开销,专注于GC;
ParNew 复制 新生 并行 多CPU;Server模式下 缺:stop the world;优:并行并发GC
Parallel Scavenge 复制 新生 并行 吞吐量控制,Client,server均可以 主要关注吞吐量,通过吞吐量的设置控制停顿时间,适应不同的场景
Serial Old 整理 老年 串行 主要Client模式下 缺:stop the world;其他收集器搞不定时的保底选择
Parallel Old 整理 老年 并行 吞吐量控制,Client,server均可以 主要关注吞吐量,通过吞吐量的设置控制停顿时间,适应不同的场景
CMS 清除 老年 并发 互联网站;B/S系统服务端 缺:CPU资源敏感,无法处理浮动垃圾,产生大量内存碎片;优:并发收集,低停顿
G1 整理 均可 并发 面向服务端应用 优:并行与并发,分代收集,空间整合(标记整理算法),可预测停顿

2)一些规律

  • 新生代都是标记 - 复制算法,老年代采用标记 - 整理,或清除(CMS)
  • 历史性的收集器大多针对某个代,但是G1,以及未来的ZGC都是全代可用
  • 没有绝对好用的收集器,需要在 吞吐量、延迟性、内存占用量上做权衡
    • 数据分析、科学计算等场合,偏重吞吐量
    • 互联网服务器、web网站,偏重服务的延迟度,不能出现严重顿挫
    • 客户端、微型终端、嵌入式应用,内存占用低是关键

3)搭配组合

除了G1和ZGC这些全能选手,其他垃圾收集器需要搭配工作

但是组合不是想怎么来就怎么来的,下图展示可用组合,以及在某些版本中废弃掉的组合:

image-20210402114507424

4)如何查看当前jdk的垃圾回收器呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java -XX:+PrintCommandLineFlags -version

#jdk8,默认Parallel
shawn@macpro:~ > java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

#jdk11,默认换成了G1
shawn@macpro:~ > java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version "11.0.2" 2019-01-15 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.2+9-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.2+9-LTS, mixed mode)

#jdk14,默认还是G1,但是已经支持ZGC了,需要打开实验性参数开关
shawn@macpro:~ > java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version "14.0.2" 2020-07-14
Java(TM) SE Runtime Environment (build 14.0.2+12-46)
Java HotSpot(TM) 64-Bit Server VM (build 14.0.2+12-46, mixed mode, sharing)

可视化GC日志分析工具

GC日志输出参数

前面通过-XX:+PrintGCDetails可以对GC日志进行打印,我们就可以在控制台查看,这样虽然可以查看GC的信息,

但是并不直观,可以借助于第三方的GC日志分析工具进行查看。

在日志打印输出涉及到的参数如下:

1
2
3
4
5
6
-XX:+PrintGC 输出GC日志 
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径

测试:

1
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:F://test//gc.log

运行后就可以在E盘下生成gc.log文件。

如下:

image-20220915212651198

GC Easy 可视化工具

GC Easy是一款在线的可视化工具,易用、功能强大,网站:

http://gceasy.io/

image-20220915212341529

上传后,点击“Analyze”按钮,即可查看报告

image-20220915212353523

image-20220915212359521

image-20220915212403718

7、调优实战

7.1 环境

  • 我们依然使用上面收集器章节相同的案例,这次针对内存来做详细分析。
  • 使用企业里主流的jdk8运行,其他jdk版本参数可能略有不同。
  • jdk自带的分析命令和工具给大家准备了扩展资料
  • 这里我们使用在线、简洁、高效的图形化分析工具: http://gceasy.io
  • gc日志参数:
1
2
3
4
5
6
7
8
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:gc.log jdk8日志文件的输出

-Xlog:gc*:gc.log jdk11日志输出方式略有不同

-XX:+printGC-verbose:gc可以打印GC的简要信息

image-20220605090010628

-XX:+PrintGCTimeStamps打印CG发生的时间戳

image-20220605085953282

-XX:+PrintGCDetails的输出

image-20220605085929352

-Xloggc指定GC log的位置,以文件输出,帮助开发人员分析问题

1
-Xloggc:log/gc.log

image-20220605085843642

-XX:+PrintHeapAtGC每次一次GC后,都打印堆信息

image-20220605085816651

7.2 初始状态

7.2.1 参数

1
-Xmx32m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.log

image-20210408162538333

7.2.2 执行日志

运行一段时间后,在项目的目录下会看到gc.log

image-20210408163439858

7.2.3 日志分析

打开 gceasy 的主页,上传后点击 Analyze 即可看到分析结果

image-20210408163653183

1)整体内存概况

young与old全部吃满,均明显不足,需要调大,meta闲置,可以缩减

image-20210408163949306

2)整体时间情况

收集时间绝大多数在10ms以内,说明收集的速度还可以,但是回收次数明显偏多,整体吞吐量94

image-20210408164152924

3)回收前后的heap明显抖动频繁,整体偏高

image-20210408172308650

4)GC间隔时间长的点,发生大量FullGC

image-20210408172408015

5)大批量空间的有效释放在FullGC上

image-20210408172501063

6)回收释放情况

年轻代和年老代回收效果一般,回收前后的两条线甚至发生交叉,应该偏离较远才说明有明显内存下降

Metaspace比较稳定

image-20210408172628499

7)A&P:对象从年轻代晋升到老年代的情况

频繁晋升,跨代移动,说明年轻代不够用,老年代一旦堆积,极容易引发fullGC

image-20210408173001294

8)GC次数及时间统计

发生591次GC,里面竟然有45是FullGC,不可容忍!

总GC耗时,2.820s,FullGC占了将近一半

image-20210408173408882

9)GC停顿情况

总耗时、平均每次GC耗时 4.77ms

image-20210408173756291

10)GC被触发的原因

常规达到阈值垃圾回收45次。

内存分配失败引发回收,这个会影响正常业务执行。

image-20210408173912968

7.3 初步调优

7.3.1 参数

分析上面的情况,明显年轻代老年代内存均严重不足,那么最简单粗暴的方式,我们加大内存

1
-Xms256m -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.log

7.3.2 二次分析

重复上面的步骤,新开一个浏览器页签,方便对比分析日志,重点关注几个点:

1)总内存够用了,但是年轻代依然被吃爆。年老代闲置。

image-20210408180257479

2)吞吐量上升,耗时特别长的gc分部区间明显减少,甚至消失

image-20210408180340673

3)gc前后的空间曲线对比明显

image-20210408180429177

4)FullGC消失!

image-20210408180505228

5)GC大批量的内存释放发生在了年轻代

image-20210408180546815

6)年轻代的回收前后两条曲线不再交叉,被明显剥离

image-20210408180621878

7)年老代表示情绪稳定

image-20210408180647712

8)年轻到老年代的晋升明显减少

image-20210408180717968

9)FullGC完全消失,总GC次数明显减少到49,总停顿时间从上次的2.8s降低到0.3s

image-20210408180822553

10)晋升的对象明显减少,创建速度提升

image-20210408181044095

11)不再发生内存分配失败造成gc的现象

image-20210408181203637

7.4 二次调优

7.4.1 参数

结合上次调优,我们发现,年轻代依然不够用,年老代闲置,对象还是会频繁从年轻代晋升到年老代。

结合我们的业务场景,大批量对象在请求后会被释放,属于短生命周期。包括我们现实中从数据库请求发送到网页后对象就完成了实名,属于同类场景。

所以,加大年轻代比例!

1
-Xms256m -Xmx256m -XX:NewSize=250m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.log

7.4.2 日志分析

同样是256的内存,我们再次跑出日志分析看看差异

1)年轻代已基本够用,很少有对象再跑到老年代

image-20210408182302407

2)吞吐量进一步上升

image-20210408182416738

思考一下,那为什么pause的平均时间还变长了呢???

答:次数变少了,单次需要收集的对象多了,所以肯定要占时间,我们接着往下看总耗时!

3)堆回收空间变化明显

image-20210408185109798

4)gc次数明显下降到8次,总时间进一步降低到80ms

image-20210408185136455

7.5 小结

  • 机器主要用来跑java服务的话,一般是需要调优的。因为默认堆最大只占物理内存的1/4
  • jvm的调优没有标准可言,不同业务场景的内存占用和增长情况不同。调整需要根据结果一步步来,直到最优。
  • 调优工具很多,gceasy属于简单直观的ui操作,jvm自带工具大多是命令行且功能较少,在扩展资料里。
  • parallel是jdk8下的默认收集器,切换不同收集器后的调试与上面过程一致,感兴趣的同学可以逐个尝试。

8、性能监控工具

系统性能监控

确定系统运行的整体状态,基本定位问题所在

Linux

uptime

image-20220606080320531

  • 系统时间
    • 例子中当前系统时间为 17:03:20
  • 运行时间
    • 例子中为7分钟
  • 连接数
    • 每一个终端算一个连接,例子中为3users
  • 1,5,15分钟内的系统平均负载
    • 运行队列中的平均进程数,例子中为load average: 0.80,0.93,0.50,值越大表示系统负载越重

top

image-20220606080750238

vmstat

  • 可以统计系统的CPU,内存,swap,io等情况
  • CPU占用率很高,上下文切换频繁,说明系统有线程正在频繁切换

image-20220606080928760

采样频率和采样次数,例子中表示1s一次,采样4条记录

pidstat

可以细致观察进程,需要安装,监控CPU,监控IO,监控内存

1
sudo apt-get install sysstat

image-20220606081229638

image-20220606081256030

获取到哪个线程有问题,到时候就可以在java中查找哪个线程有问题了

-d显示磁盘IO的情况

image-20220606081431416

windows

任务管理器

image-20220606081544716

可以选择列展示

image-20220606081555781

Perfmon

Windows自带多功能性能监控工具,cmd命令行可以打开

image-20220606081613952

image-20220606081625438

image-20220606081630749

image-20220606081638015

定位到了2368的线程占用了大量的系统资源

Process Explorer

image-20220606081654137

怎么找到系统内最消耗CPU的线程?

pslist

命令行工具,可用于自动化数据收集,显示java程序的运行情况

image-20220606082300923

这里cpu是多核,所以cpu时间大于流逝时间

image-20220606082334018

Java自带的工具

查看Java程序运行细节,进一步定位问题

image-20220606082346125

jps

列出java进程,类似于ps命令

  • 参数-q可以指定jps只输出进程ID ,不输出类的短名称
  • 参数-m可以用于输出传递给Java进程(主函数)的参数
  • 参数-l可以用于输出主函数的完整路径
  • 参数-v可以显示传递给JVM的参数

image-20220606082620699

image-20220606082633311

jinfo

可以用来查看正在运行的Java应用程序的扩展参数,甚至支持在运行时,修改部分参数

  • -flag <name>:打印指定JVM的参数值
  • -flag [+|-]<name>:设置指定JVM参数的布尔值
  • -flag <name>=<value>:设置指定JVM参数的值

显示了新生代对象晋升到老年代对象的最大年龄

1
jinfo -flag MaxTenuringThreshold 2972-XX:MaxTenuringThreshold=15

显示是否打印GC详细信息

1
jinfo -flag PrintGCDetails  2972-XX:-PrintGCDetails

运行时修改参数,控制是否输出GC日志

1
2
3
jinfo -flag PrintGCDetails  2972-XX:-PrintGCDetails
jinfo -flag +PrintGCDetails 2972
jinfo -flag PrintGCDetails 2972-XX:+PrintGCDetails

jmap

前文有用到jstat对jvm堆内存做统计分析,而jmap可以获取更详细的内存,比如:内存使用情况的汇总、对内存溢出的定位与分析等。

查看内存使用情况

jmap -heap 进程ID

image-20220830190141761

查看内存中对象数量及大小

1
2
3
4
# 查看所有对象,包括活跃和非活跃的
jmap -histo <pid> | more
# 查看活跃对象
jmap -histo:live <pid> | more

image-20220830190632245

1
2
3
4
5
6
7
8
9
10
#对象说明 
B byte
C char
D double
F float
I int
J long
Z boolean
[ 数组,如[I表示int[]
[L+类名 其他对象

生成Java应用程序的堆快照和对象的统计信息

1
jmap -histo 2972 >c:\s.txt

image-20220606083423253

将内存使用情况Dump到文件中

jmap -dump:format=b,file=c:\heap.hprof 2972,后续可以根据这个文件做进一步的分析。

1
2
3
4
jmap -dump:format=b,file=c:\heap.hprof 2972

Dumping heap to C:\heap.hprof ...
Heap dump file created

图片效果:

image-20220830191111078

image-20220606083516498

对dump文件分析的方法很多

1.通过jhat对dump文件进行分析

dump文件时二进制的,不方便查看,用jhat就可以: jhat -port <port> <file>

image-20220830191437756

访问:http://localhost:9999/

image-20220830191504682

比如我们还可以通过OQL查看,生产环境中的dump文件是否有长度很大的。就可以去分析是否合理之类的

image-20220830192028935

2.通过MAT分析dump文件

见下文JAVA堆分析#MAT使用基础

jstack

有时候我们需要查看下jvm中的线程执行情况,比如,发现服务的CPU的负载突然增高了,出现了死锁,死循环等。我们该怎么分析?

由于程序时正常运行的,没有任何的输出,从日志方面也看不出什么问题,所以就需要看下jvm的内部线程的执行情况,然后在进行分析查找出原因。

这个时候,就需要借助于jstack命令了,jstack的作用是将正在运行的jvm的线程情况进行快照,并且打印出来。

打印线程dump

  • -l 打印锁信息

  • -m 打印java和native的帧信息

  • -F 强制dump,当jstack没有响应时使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
jstack 120 >>C:\a.txt

"main" #1 prio=6 os_prio=0 tid=0x0831c400 nid=0xecc runnable [0x0018f000]
java.lang.Thread.State: RUNNABLE
at org.eclipse.swt.internal.win32.OS.WaitMessage(Native Method)
at org.eclipse.swt.widgets.Display.sleep(Display.java:4657)
at org.eclipse.ui.application.WorkbenchAdvisor.eventLoopIdle(WorkbenchAdvisor.java:364)
at org.eclipse.ui.internal.ide.application.IDEWorkbenchAdvisor.eventLoopIdle(IDEWorkbenchAdvisor.java:917)
at org.eclipse.ui.internal.Workbench$3.eventLoopIdle(Workbench.java:487)
at org.eclipse.e4.ui.internal.workbench.swt.PartRenderingEngine$9.run(PartRenderingEngine.java:1117)
at org.eclipse.core.databinding.observable.Realm.runWithDefault(Realm.java:332)
at org.eclipse.e4.ui.internal.workbench.swt.PartRenderingEngine.run(PartRenderingEngine.java:997)
at org.eclipse.e4.ui.internal.workbench.E4Workbench.createAndRunUI(E4Workbench.java:140)
at org.eclipse.ui.internal.Workbench$5.run(Workbench.java:611)
at org.eclipse.core.databinding.observable.Realm.runWithDefault(Realm.java:332)
at org.eclipse.ui.internal.Workbench.createAndRunWorkbench(Workbench.java:567)
at org.eclipse.ui.PlatformUI.createAndRunWorkbench(PlatformUI.java:150)
at org.eclipse.ui.internal.ide.application.IDEApplication.start(IDEApplication.java:124)
at org.eclipse.equinox.internal.app.EclipseAppHandle.run(EclipseAppHandle.java:196)
at org.eclipse.core.runtime.internal.adaptor.EclipseAppLauncher.runApplication(EclipseAppLauncher.java:110)
at org.eclipse.core.runtime.internal.adaptor.EclipseAppLauncher.start(EclipseAppLauncher.java:79)
at org.eclipse.core.runtime.adaptor.EclipseStarter.run(EclipseStarter.java:354)
at org.eclipse.core.runtime.adaptor.EclipseStarter.run(EclipseStarter.java:181)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.lang.reflect.Method.invoke(Unknown Source)
at org.eclipse.equinox.launcher.Main.invokeFramework(Main.java:636)
at org.eclipse.equinox.launcher.Main.basicRun(Main.java:591)
at org.eclipse.equinox.launcher.Main.run(Main.java:1450)

JConsole

图形化监控工具

可以查看Java应用程序的运行概况,监控堆信息、永久区使用情况、类加载情况等

image-20220606083624401

image-20220606083630876

image-20220606083638583

image-20220606083649037

Visual VMVisual

VM是一个功能强大的多合一故障诊断和性能监控的可视化工具

VisualVM,能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈(如100个String对象分别由哪几个对象分配出来的)。

VisualVM使用简单,几乎0配置,功能还是比较丰富的,几乎囊括了其它JDK自带命令的所有功能。

  • 内存信息
  • 线程信息
  • Dump堆(本地进程)
  • Dump线程(本地进程)
  • 打开堆Dump。堆Dump可以用jmap来生成。
  • 打开线程Dump
  • 生成应用快照(包含内存信息、线程信息等等)
  • 性能分析。CPU分析(各个方法调用时间,检查哪些方法耗时多),内存分析(各类对象占用的内存,检查
  • 哪些类占用内存多)
  • ……

启动

在jdk的安装目录的bin目录下,找到jvisualvm.exe,双击打开即可。

image-20220915195851918

image-20220915195903417

查看本地进程

image-20220915195919563

查看CPU、内存、类、线程运行信息

image-20220915195953453

查看线程详情

image-20220915200019581

也可以点击右上角Dump按钮,将线程的信息导出,其实就是执行的jstack命令。

image-20220915200032331

发现,显示的内容是一样的。

抽样器

抽样器可以对CPU、内存在一段时间内进行抽样,以供分析。

image-20220915200052275

image-20220606083726599

image-20220606084526922

image-20220606084502784

性能监控:找到占用CPU时间最长的方法

image-20220606083837307

分析堆Dump

image-20220606083856748

怎么观察Java线程打印?

监控远程的jvm

VisualJVM不仅是可以监控本地jvm进程,还可以监控远程的jvm进程,需要借助于JMX技术实现。

什么是JMX?

JMX(Java Management Extensions,即Java管理扩展)是一个为应用程序、设备、系统等植入管理功能的框架。

JMX可以跨越一系列异构操作系统平台、系统体系结构和网络传输协议,灵活的开发无缝集成的系统、网络和服务管理应用。

监控远程的tomcat

想要监控远程的tomcat,就需要在远程的tomcat进行对JMX配置,方法如下:

1
2
3
4
5
6
7
8
9
#在tomcat的bin目录下,修改catalina.sh,添加如下的参数 
JAVA_OPTS="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -
Dcom.sun.management.jmxremote.authenticate=false -
Dcom.sun.management.jmxremote.ssl=false"
#这几个参数的意思是:
#-Dcom.sun.management.jmxremote :允许使用JMX远程管理
#-Dcom.sun.management.jmxremote.port=9999 :JMX远程连接端口
#-Dcom.sun.management.jmxremote.authenticate=false :不进行身份认证,任何用户都可以连接
#-Dcom.sun.management.jmxremote.ssl=false :不使用ssl

保存退出,重启tomcat。

使用VisualJVM连接远程tomcat

添加远程主机:

image-20220915200227524

在一个主机下可能会有很多的jvm需要监控,所以接下来要在该主机上添加需要监控的jvm:

image-20220915200243057

image-20220915200254116

连接成功。使用方法和前面就一样了,就可以和监控本地jvm进程一样,监控远程的tomcat进程。

实战分析

案例问题

普通分析

运行一程序ThreadBlockMain,期望输出Hello,World ,结果在程序运行后,程序卡死,没有预期输出

image-20220606084817434

Java程序HoldCPUMain运行后,发现占用CPU很高,希望能找到原因。

image-20220606083946248

image-20220606083952669

3467占用资源比较多,要转成16进制,到jstack里面找。线程的ID号在jstack中是使用16进制表示的。即0xd8b

image-20220606084000260

定位到代码了,去HoldxxxTask.run 第8行去看它代码做的操作是什么,导致CPU高

死锁分析

image-20220606084037591

如何从jstack的输出中找出死锁

image-20220606084732456

image-20220606084714400

9、Java堆分析

内存溢出(OOM)的原因

在JVM中,有哪些内存区间?堆、永久区、线程栈、直接内存

堆溢出

1
2
3
4
5
6
7
8
public static void main(String args[]){
ArrayList<byte[]> list=new ArrayList<byte[]>();
for(int i=0;i<1024;i++){
list.add(new byte[1024*1024]); // 占用大量堆空间,直接溢出
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at geym.jvm.ch8.oom.SimpleHeapOOM.main(SimpleHeapOOM.java:14)

解决方法:增大堆空间(xmx),及时释放内存

永久区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
生成大量的类
public static void main(String[] args) {
for(int i=0;i<100000;i++){
CglibBean bean = new CglibBean("geym.jvm.ch3.perm.bean"+i,new HashMap());
}
}

Caused by: java.lang.OutOfMemoryError: PermGen space
[Full GC[Tenured: 2523K->2523K(10944K), 0.0125610 secs] 2523K->2523K(15936K),
[Perm : 4095K->4095K(4096K)], 0.0125868 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
def new generation total 4992K, used 89K [0x28280000, 0x287e0000, 0x2d7d0000)
eden space 4480K, 2% used [0x28280000, 0x282966d0, 0x286e0000)
from space 512K, 0% used [0x286e0000, 0x286e0000, 0x28760000)
to space 512K, 0% used [0x28760000, 0x28760000, 0x287e0000)
tenured generation total 10944K, used 2523K [0x2d7d0000, 0x2e280000, 0x38280000)
the space 10944K, 23% used [0x2d7d0000, 0x2da46cf0, 0x2da46e00, 0x2e280000)
compacting perm gen total 4096K, used 4095K [0x38280000, 0x38680000, 0x38680000)
the space 4096K, 99% used [0x38280000, 0x3867fff0, 0x38680000, 0x38680000) // 永久区满了
ro space 10240K, 44% used [0x38680000, 0x38af73f0, 0x38af7400, 0x39080000)
rw space 12288K, 52% used [0x39080000, 0x396cdd28, 0x396cde00, 0x39c80000)

解决方法:增大Perm区,允许Class回收

Java栈溢出

这里的栈溢出指,在创建线程的时候,需要为线程分配栈空间,这个栈空间是向操作系统请求的,如果操作系统无法给出足够的空间,就会抛出OOM

image-20220606163743486

比如这里-Xmx1g -Xss1m,堆设置1G,栈设置1M

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static class SleepThread implements Runnable{
public void run(){
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String args[]){
for(int i=0;i<1000;i++){
new Thread(new SleepThread(),"Thread"+i).start();
System.out.println("Thread"+i+" created");
}
}

Exception in thread "main" java.lang.OutOfMemoryError:
unable to create new native thread

解决方法:减少堆内存,减少线程栈大小

直接内存溢出

ByteBuffer.allocateDirect()无法从操作系统获得足够的空间

image-20220606165142149

image-20220606165212147

遇到内存溢出后,应该如何思考和处理问题?

MAT使用基础

基于Eclipse的软件: http://www.eclipse.org/mat/

下载地址:https://www.eclipse.org/mat/downloads.php

image-20230928164211501

解压后打开使用

SouthEast

柱状图显示

显示每个类的使用情况,比如类的数量,所占空间等

image-20220606170033631

显示支配树

image-20220606170053073

支配树的概念

  • 在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B
  • 如果对象A是离对象B最近的一个支配对象,则认为对象A为对象B的直接支配者

对象引用图转为支配树的图:

image-20220606170126633

支配者被回收,被支配对象也被回收

显示线程信息

image-20220606170259604

显示堆总体信息

比如消耗最大的一些对象等

image-20220606170318998

image-20220606170342853

浅堆(Shallow Heap)与深堆(Retained Heap)

image-20220606170405414

浅堆:

  • 一个对象结构所占用的内存大小

image-20220606170506060

JDK7后,String结构发生变化

  • 3个int类型以及一个引用类型合计占用内存3*4+4=16个字节。再加上对象头的8个字节,因此String对象占用的空间,即浅堆的大小是16+8=24字节
  • 对象大小按照8字节对齐
  • 浅堆大小和对象的内容无关,只和对象的结构有关

深堆

  • 一个对象被GC回收后,可以真实释放的内存大小
  • 只能通过对象访问到的(直接或者间接)所有对象的浅堆之和 (支配树)

image-20220606170644174

image-20220606170655137

image-20220606170705947

使用Visual VM分析堆

java自带的多功能分析工具,可以用来分析堆Dump

image-20220606170738109

image-20220606170751054

image-20220606170801125

使用OOL查询

image-20220606170822629

返回引用了(0,0)这个点的所有对象

image-20220606170838535

Tomcat OOM分析案例

模拟OOM案例例子

1
2
测视案例可以设置参数: -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError 
8M时为了测试案例快速填满内存,这样内存溢出时会输出到dump文件

image-20220915193819177

Tomcat OOM

​ Tomcat 在接收大量请求时发生OOM,获取堆Dump文件,进行分析。分析目的:

  • 找出OOM的原因
  • 推测系统OOM时的状态
  • 给出解决这个OOM的方法

使用MAT打开堆

image-20220606170913027

可以看到StandardManager占用了大量的空间

我们这里关心它引用了哪些对象,outgoing references

image-20220606171003239

image-20220606171016873

可以看到ConcurrentHashMap占用了大量空间

image-20220606171030117

打开sessions查看内容

image-20220606171038705

确认是否有大量session

image-20220606171049525

可以看到有9963个对象,每个对象1.5k左右

选中任意session查看详细信息

image-20220606171109911

这里20分钟session对象才过期

image-20220606171123052

这里最后一个session-第一个session的创建时间,就是持续的时间。创建的频率为:320次/s

解决方法:

  1. OOM由于保存session过多引起,可以考虑增加堆大小
  2. 如果应用允许,缩短session的过期时间,使得session可以及时过期,并回收

10、tomcat8优化

tomcat服务器在JavaEE项目中使用率非常高,所以在生产环境对tomcat的优化也变得非常重要了。

对于tomcat的优化,主要是从2个方面入手,一是,tomcat自身的配置,另一个是tomcat所运行的jvm虚拟机的调优。

下面我们将从这2个方面进行讲解。

tomcat配置优化

部署安装tomcat8

下载并安装:

https://tomcat.apache.or/download-80.cgi

image-20220915214032806

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cd /tmp 
wget http://mirrors.tuna.tsinghua.edu.cn/apache/tomcat/tomcat-8/v8.5.34/bin/apache-
tomcat-8.5.34.tar.gz
tar -xvf apache-tomcat-8.5.34.tar.gz
cd apache-tomcat-8.5.34/conf

#修改配置文件,配置tomcat的管理用户
vim tomcat-users.xml

#写入如下内容:
<role rolename="manager"/>
<role rolename="manager-gui"/>
<role rolename="admin"/>
<role rolename="admin-gui"/>
<user username="tomcat" password="tomcat" roles="admin-gui,admin,manager-gui,manager"/>
#保存退出
#如果是tomcat7,配置了tomcat用户就可以登录系统了,但是tomcat8中不行,还需要修改另一个配置文件,否则访 问不了,提示403

vim webapps/manager/META-INF/context.xml

#将<Valve的内容注释掉

<Context antiResourceLocking="false" privileged="true" >
<!-- <Valve className="org.apache.catalina.valves.RemoteAddrValve"
allow="127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1" /> -->
<Manager sessionAttributeValueClassNameFilter="java\.lang\. (?:Boolean|Integer|Long|Number|String)|org\.apache\.catalina\.filters\.CsrfPreventionFilter\$LruCache(?:\$1)?|java\.util\.(?:Linked)?HashMap"/>
</Context>

#保存退出即可

#启动tomcat
cd /tmp/apache-tomcat-8.5.34/bin/
./startup.sh && tail -f ../logs/catalina.out

#打开浏览器进行测试访问
http://192.168.40.133:8080/

image-20220915214236019

点击“Server Status”,输入用户名、密码进行登录,tomcat/tomcat

image-20220915214252993

image-20220915214302866

进入之后即可看到服务的信息。

禁用AJP连接

在服务状态页面中可以看到,默认状态下会启用AJP服务,并且占用8009端口。

image-20220915214333870

什么是AJP呢?

AJP(Apache JServer Protocol) AJPv13协议是面向包的。WEB服务器和Servlet容器通过TCP连接来交互;为了节省SOCKET创建的昂贵代价,WEB服务器会尝试维护一个永久TCP连接到servlet容器,并且在多个请求和响应周期过程会重用连接。

image-20220915214346775

我们一般是使用Nginx+tomcat的架构,所以用不着AJP协议(apache的才能用),所以把AJP连接器禁用。

修改conf下的server.xml文件,将AJP服务禁用掉即可。

1
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />

image-20220915214405866

重启tomcat,查看效果。

image-20220915214453339

可以看到AJP服务以及不存在了。

执行器(线程池)

在tomcat中每一个用户请求都是一个线程,所以可以使用线程池提高性能。

修改server.xml文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--将注释打开--> 
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
maxThreads="500" minSpareThreads="50" prestartminSpareThreads="true"
maxQueueSize="100"/>
<!--
参数说明:
maxThreads:最大并发数,默认设置 200,一般建议在 500 ~ 1000,根据硬件设施和业务来判断
minSpareThreads:Tomcat 初始化时创建的线程数,默认设置 25
prestartminSpareThreads: 在 Tomcat 初始化的时候就初始化 minSpareThreads 的参数值,如果不等于 true,minSpareThreads 的值就没啥效果了
maxQueueSize,最大的等待队列数,超过则拒绝请求
-->

<!--在Connector中设置executor属性指向上面的执行器-->
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />

保存退出,重启tomcat,查看效果。

image-20220915214833821

在页面中显示最大线程数为-1,这个是正常的,仅仅是显示的问题,实际使用的指定的值。

也有人遇到这样的问题:https://blog.csdn.net/weixin_38278878/article/details/80144397

3种运行模式

tomcat的运行模式有3种:

  1. bio 默认的模式,性能非常低下,没有经过任何优化处理和支持.

  2. nio nio(new I/O),是Java SE 1.4及后续版本提供的一种新的I/O操作方式(即java.nio包及其子包)。Java nio是一个基于缓冲区、并能提供非阻塞I/O操作的Java API,因此nio也被看成是non-blocking I/O的缩写。它拥有比传统I/O操作(bio)更好的并发运行性能。

  3. apr 安装起来最困难,但是从操作系统级别来解决异步的IO问题,大幅度的提高性能.

推荐使用nio,不过,在tomcat8中有最新的nio2,速度更快,建议使用nio2.

设置nio2:

1
2
3
4
<Connector executor="tomcatThreadPool" port="8080" 
protocol="org.apache.coyote.http11.Http11Nio2Protocol"
connectionTimeout="20000"
redirectPort="8443" />

image-20220915214941286

可以看到已经设置为nio2了。

部署测试用的java web项目

为了方便测试性能,我们将部署一个java web项目,这个项目本身和本套课程没有什么关系,仅仅用于测试。

注意:这里在测试时,我们使用一个新的tomcat,进行测试,后面再对其进行优化调整,再测试。

创建dashboard数据库

在资料中找到sql脚本文件dashboard.sql,在linux服务器上执行。

1
cat dashboard.sql | mysql -uroot -proot

创建完成后,可以看到有3张表。

image-20220915215118778

部署web应用

在资料中找到itcat-dashboard-web.war,上传到linux服务器,进行部署安装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cd /tmp/apache-tomcat-8.5.34/webapps 
rm -rf *

mkdir ROOT
cd ROOT/

rz上传war包
jar -xvf itcat-dashboard-web.war
rm -rf itcat-dashboard-web.war

#修改数据库配置文件
cd /tmp/apache-tomcat-8.5.34/webapps/ROOT/WEB-INF/classes

vim jdbc.properties
#这里根据自己的实际情况进行配置

jdbc.driverClassName=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://node01:3306/dashboard?
useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true
jdbc.username=root
jdbc.password=root

重新启动tomcat。

访问首页,查看是否已经启动成功:http://192.168.40.133:8080/index

image-20220915215234078

使用Apache JMeter进行测试

Apache Jmeter是开源的压力测试工具,我们借助于此工具进行测试,将测试出tomcat的吞吐量等信息。

下载安装

下载地址:http://jmeter.apache.org/download_jmeter.cgi

image-20220915215315023

安装:直接将下载好的zip压缩包进行解压即可。

image-20220915215326081

进入bin目录,找到jmeter.bat文件,双机打开即可启动。

image-20220915215338170

image-20220915215342581

image-20220915215346952

修改主题和语言

默认的主题是黑色风格的主题并且语言是英语,这样不太方便使用,所以需要修改下主题和中文语言。

image-20220915215358344

image-20220915215402078

image-20220915215406268

主题修改完成。

接下来设置语言为简体中文

image-20220915215415667

语言修改完成

image-20220915215424924

创建首页的测试用例

第一步:保存测试用例

image-20220915215439536

第二步:添加线程组,使用线程模拟用户的并发

image-20220915215448563

image-20220915215454259

1000个线程,每个线程循环10次,也就是tomcat会接收到10000个请求。

第三步:添加http请求

image-20220915215503575

image-20220915215509298

第四步:添加请求监控

image-20220915215519057

image-20220915215524845

启动、进行测试

image-20220915215538130

聚合报告

在聚合报告中,重点看吞吐量。

image-20220915215549376

12s还是比较慢的。吞吐量每s有73。错误率并不是要保证为0。3个数据应该均衡考量。

调整tomcat参数进行优化

通过上面测试可以看出,tomcat在不做任何调整时,吞吐量为73次/秒。

禁用AJP服务

编辑Tomcat的config/server.xml文件

image-20220915215617009

image-20220915215621819

可以看到,禁用AJP服务后,吞吐量会有所提升。

当然了,测试不一定准确,需要多测试几次才能看出是否有提升

设置线程池

通过设置线程池,调整线程池相关的参数进行测试tomcat的性能。

最大线程数为500,初始为50

1
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="500" minSpareThreads="50" prestartminSpareThreads="true"/>

image-20220915220840055

测试结果:

image-20220915215703916

吞吐量为128次/秒,性能有所提升。

最大线程数为1000,初始为200

1
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="1000" minSpareThreads="200" prestartminSpareThreads="true"/>

image-20220915215735978

吞吐量为151,性能有所提升

最大线程数为5000,初始为1000

是否是线程数最多,速度越快呢? 我们来测试下。

1
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="5000" minSpareThreads="1000" prestartminSpareThreads="true"/>

image-20220915215808314

可以看到,虽然最大线程已经设置到5000,但是实际测试效果并不理想,并且平均的响应时间也边长了,所以单纯靠提升线程数量是不能一直得到性能提升的。

设置最大等待队列数

默认情况下,请求发送到tomcat,如果tomcat正忙,那么该请求会一直等待。这样虽然可以保证每个请求都能请求到,但是请求时间就会边长。

有些时候,我们也不一定要求请求一定等待,可以设置最大等待队列大小,如果超过就不等待了。这样虽然有些请求是失败的,但是请求时间会缩短。典型的应用:12306。

1
2
3
<!--最大等待数为100--> 
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="500" minSpareThreads="100" prestartminSpareThreads="true"
maxQueueSize="100"/>

image-20220915215843384

测试结果:

  • 平均响应时间:3.1秒
    • 响应时间明显缩短
  • 错误率:49.88%
    • 错误率提升到一半,也可以理解,最大线程为500,测试的并发为1000
  • 吞吐量:238次/秒
    • 吞吐量明显提升

这样对参数调整、取到Tomcat的最佳状态(响应时间短,吞吐量高),至于错误率可以通过Tomcat集群规避。

结论:响应时间、吞吐量这2个指标需要找到平衡才能达到更好的性能。

设置nio2的运行模式

将最大线程设置为500进行测试:

1
2
3
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="500" minSpareThreads="50" prestartminSpareThreads="true"/> 
<!-- 设置nio2 -->
<Connector executor="tomcatThreadPool" port="8080" protocol="org.apache.coyote.http11.Http11Nio2Protocol" connectionTimeout="20000" redirectPort="8443" />

image-20220915215932563

可以看到,平均响应时间有缩短,吞吐量有提升,可以得出结论:nio2的性能要高于nio

调整JVM参数进行优化

接下来,测试通过jvm参数进行优化,为了测试一致性,依然将最大线程数设置为500,启用nio2运行模式。

设置并行垃圾回收器

1
2
#年轻代、老年代均使用并行收集器,初始堆内存64M,最大堆内存512M 
JAVA_OPTS="-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms64m -Xmx512m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"

image-20220915221641646

测试结果与默认的JVM参数结果接近。(执行了2次测试,结果是第二次测试的结果)

查看gc日志文件

将gc.log文件上传到gceasy.io查看gc中是否存在问题。

问题一:

image-20220915221712247

在报告中显示,在5次GC时,系统所消耗的时间大于用户时间,这反应出的服务器的性能存在瓶颈,调度CPU等资源所消耗的时间要长一些。

问题二:

image-20220915221724999

可以关键指标中可以看出,吞吐量表现不错,但是gc时,线程的暂停时间稍有点长。

问题三:

image-20220915221735595

通过GC的统计可以看出:

  • 年轻代的gc有74次,次数稍有多,说明年轻代设置的大小不合适需要调整
  • FullGC有8次,说明堆内存的大小不合适,需要调整

问题四:

image-20220915221752545

从GC原因的可以看出,年轻代大小设置不合理,导致了多次GC。

调整年轻代大小

1
JAVA_OPTS="-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms128m -Xmx1024m -XX:NewSize=64m -XX:MaxNewSize=256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"

将初始堆大小设置为128m,最大为1024m

初始年轻代大小64m,年轻代最大256m

image-20220915221833571

从测试结果来看,吞吐量以及响应时间均有提升。

查看gc日志:

image-20220915221842901

image-20220915221847115

可以看到GC次数要明显减少,说明调整是有效的。

设置G1垃圾回收器

1
2
#设置了最大停顿时间100毫秒,初始堆内存128m,最大堆内存1024m 
JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms128m -Xmx1024m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"

测试结果:

image-20220915221928684

image-20220915221932888

可以看到,吞吐量有所提升,评价响应时间也有所缩短。

小结

通过上述的测试,可以总结出,对tomcat性能优化就是需要不断的进行调整参数,然后测试结果,可能会调优也可能会调差,这时就需要借助于gc的可视化工具来看gc的情况。再帮我我们做出决策应该调整哪些参数。

11、从字节码角度验证代码优化

前面我们通过tomcat本身的参数以及jvm的参数对tomcat做了优化,其实要想将应用程序跑的更快、效率更高,除了对tomcat容器以及jvm优化外,应用程序代码本身如果写的效率不高的,那么也是不行的,所以,对于程序本身的优化也就很重要了。

对于程序本身的优化,可以借鉴很多前辈们的经验,但是有些时候,在从源码角度方面分析的话,不好鉴别出哪个效率高,如对字符串拼接的操作,是直接“+”号拼接效率高还是使用StringBuilder效率高?

这个时候,就需要通过查看编译好的class文件中字节码,就可以找到答案。

我们都知道,java编写应用,需要先通过javac命令编译成class文件,再通过jvm执行,jvm执行时是需要将class文件中的字节码载入到jvm进行运行的。

通过javap命令查看class文件的字节码内容

首先,看一个简单的Test1类的代码:

1
2
3
4
5
6
7
8
9
package cn.itcast.jvm; 
public class Test1 {
public static void main(String[] args) {
int a = 2;
int b = 5;
int c = b - a;
System.out.println(c);
}
}

通过javap命令查看class文件中的字节码内容:

1
javap -v Test1.class > Test1.txt

image-20220915223204417

查看Test1.txt文件,内容如下:

image-20220915223337036

image-20220915223340666

内容大致分为4个部分:

第一部分:显示了生成这个class的java源文件、版本信息、生成时间等。

第二部分:显示了该类中所涉及到常量池,共35个常量。

第三部分:显示该类的构造器,编译器自动插入的。

第四部分:显示了main方的信息。(这个是需要我们重点关注的)

常量池

官网文档:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.4-140

image-20220915223510008

描述符

字段描述符
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.2

image-20220915223725266

方法描述符

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.3
示例:

The method descriptor for the method

1
Object m(int i, double d, Thread t) {...}

is:

1
(IDLjava/lang/Thread;)Ljava/lang/Object;

解读方法字节码

image-20220915223833230

图解

image-20220915223853427

image-20220915223857345

image-20220915223904353

研究 i++ ++i 的不同

我们都知道,i++表示,先返回再+1,++i表示,先+1再返回。它的底层是怎么样的呢? 我们一起探究下。

编写测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Test2 { 
public static void main(String[] args) {
new Test2().method1();
new Test2().method2();

}
public void method1(){
int i = 1;
int a = i++;
System.out.println(a); //打印1
}
public void method2(){
int i = 1;
int a = ++i;
System.out.println(a);//打印2
}

}

查看class字节码

image-20220915224152947

image-20220915224157923

image-20220915224201382

对比

i++:

image-20220915224243764

++i:

image-20220915224255030

区别:

i++

  • 只是在本地变量中对数字做了相加,并没有将数据压入到操作栈
  • 将前面拿到的数字1,再次从操作栈中拿到,压入到本地变量中

++i

  • 将本地变量中的数字做了相加,并且将数据压入到操作栈
  • 将操作栈中的数据,再次压入到本地变量中

小结:可以通过查看字节码的方式对代码的底层做研究,探究其原理。

字符串拼接

字符串的拼接在开发过程中使用是非常频繁的,常用的方式有三种:

  • +号拼接: str+”456”
  • StringBuilder拼接
  • StringBuffer拼接

StringBuffer是保证线程安全的,效率是比较低的,我们更多的是使用场景是不会涉及到线程安全的问题的,所以更多的时候会选择StringBuilder,效率会高一些。

那么,问题来了,StringBuilder和“+”号拼接,哪个效率高呢?接下来我们通过字节码的方式进行探究。

首先,编写个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package cn.itcast.jvm; 
public class Test3 {
public static void main(String[] args) {
new Test3().m1();
new Test3().m2();
}

public void m1(){
String s1 = "123";
String s2 = "456";
String s3 = s1 + s2;
System.out.println(s3);
}

public void m2(){
String s1 = "123";
String s2 = "456";
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
String s3 = sb.toString();
System.out.println(s3);
}
}

查看Test3.class的字节码

image-20220915224426867

image-20220915224441718

image-20220915224513988

image-20220915224528841

image-20220915224556272

image-20220915224637051

从解字节码中可以看出,m1()方法源码中是使用+号拼接,但是在字节码中也被编译成了StringBuilder方式。

所以,可以得出结论,字符串拼接,+号和StringBuilder是相等的,效率一样。

接下来,我们再看一个案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package cn.itcast.jvm; 
public class Test4 {
public static void main(String[] args) {
new Test4().m1();
new Test4().m2();
}

public void m1(){
String str = "";
for (int i = 0; i < 5; i++) {
str = str + i;
}

System.out.println(str);
}

public void m2(){
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
sb.append(i);
}
System.out.println(sb.toString());
}
}

m1() 与 m2() 哪个方法的效率高?

依然是通过字节码的方式进行探究。

image-20220915224744560

image-20220915224812494

image-20220915224856682

image-20220915224959101

可以看到,m1()方法中的循环体内,每一次循环都会创建StringBuilder对象,效率低于m2()方法。

小结

使用字节码的方式可以很好查看代码底层的执行,从而可以看出哪些实现效率高,哪些实现效率低。可以更好的对我们的代码做优化。让程序执行效率更高。

12、代码优化

优化,不仅仅是在运行环境进行优化,还需要在代码本身做优化,如果代码本身存在性能问题,那么在其他方面再怎么优化也不可能达到效果最优的。

尽可能使用局部变量

调用方法时传递的参数以及在调用中创建的临时变量都保存在栈中速度较快,其他变量,如静态变量、实例变量等,都在堆中创建,速度较慢。另外,栈中创建的变量,随着方法的运行结束,这些内容就没了,不需要额外的垃圾回收。

尽量减少对变量的重复计算

明确一个概念,对方法的调用,即使方法中只有一句语句,也是有消耗的。所以例如下面的操作:

1
for (int i = 0; i < list.size(); i++) {...}

建议替换为:

1
2
int length = list.size(); 
for (int i = 0, i < length; i++) {...}

这样,在list.size()很大的时候,就减少了很多的消耗。

尽量采用懒加载的策略,即在需要的时候才创建

1
2
3
4
5
6
7
8
9
10
String str = "aaa"; 
if (i == 1){
list.add(str);
}

//建议替换成
if (i == 1){
String str = "aaa";
list.add(str);
}

异常不应该用来控制程序流程

异常对性能不利。抛出异常首先要创建一个新的对象,Throwable接口的构造函数调用名为fillInStackTrace()的本地同步方法,fillInStackTrace()方法检查堆栈,收集调用跟踪信息。只要有异常被抛出,Java虚拟机就必须调整调用堆栈,因为在处理过程中创建了一个新的对象。异常只能用于错误处理,不应该用来控制程序流程。

不要将数组声明为public static final

因为这毫无意义,这样只是定义了引用为static final,数组的内容还是可以随意改变的,将数组声明为public更是一个安全漏洞,这意味着这个数组可以被外部类所改变。

不要创建一些不使用的对象,不要导入一些不使用的类

这毫无意义,如果代码中出现”The value of the local variable i is not used”、”The import java.util is never used”,那么请删除这些无用的内容

程序运行过程中避免使用反射

反射是Java提供给用户一个很强大的功能,功能强大往往意味着效率不高。不建议在程序运行过程中使用尤其是频繁使用反射机制,特别是 Method的invoke方法。
如果确实有必要,一种建议性的做法是将那些需要通过反射加载的类在项目启动的时候通过反射实例化出一个对象并放入内存。

使用数据库连接池和线程池

这两个池都是用于重用对象的,前者可以避免频繁地打开和关闭连接,后者可以避免频繁地创建和销毁线程。

容器初始化时尽可能指定长度

容器初始化时尽可能指定长度,如:new ArrayList<>(10); new HashMap<>(32); 避免容器长度不足时,扩容带来的性能损耗。

ArrayList随机遍历快,LinkedList添加删除快

使用Entry遍历Map

1
2
3
4
5
Map<String,String> map = new HashMap<>(); 
for (Map.Entry<String,String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
}

避免使用下面这种方式:(遍历了2次)

1
2
3
4
Map<String,String> map = new HashMap<>(); 
for (String key : map.keySet()) {
String value = map.get(key);
}

不要手动调用System.gc();

String尽量少用正则表达式

正则表达式虽然功能强大,但是其效率较低,除非是有需要,否则尽可能少用。
replace() 不支持正则 replaceAll() 支持正则
如果仅仅是字符的替换建议使用replace()。

日志的输出要注意级别

1
2
// 当前的日志级别是error 
LOGGER.info("保存出错!" + user);

对资源的close()建议分开操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try{
XXX.close();
YYY.close();
}
catch (Exception e){
...
}
// 建议改为
try{
XXX.close();
}
catch (Exception e){
...
}
try{
YYY.close();
}
catch (Exception e){
...
}