性能调优15-JVM调优实战及常量池介绍

JVM调优实战及常量池介绍

GC日志详解

对于java应用我们可以通过一些配置把程序运行过程中的gc日志全部打印出来,然后分析gc日志得到关键性指标,分析

GC原因,调优JVM参数。

打印GC日志方法,在JVM参数里增加参数

1 ‐XX:+PrintGCDetails ‐XX:+PrintGCTimeStamps ‐XX:+PrintGCDateStamps ‐Xloggc:./gc.log

Tomcat则直接加在JAVA_OPTS变量里。

如何分析GC日志

普通GC日志(默认Parallel)

下图中是截取的JVM刚启动的一部分GC日志

img

我们可以看到图中第一行红框,是项目的配置参数。这里不仅配置了打印GC日志,还有相关的VM内存参数。

第二行红框中的是在这个GC时间点发生GC之后相关GC情况。

1、对于2.909: 这是从jvm启动开始计算到这次GC经过的时间,前面还有具体的发生时间日期。

2、Full GC(Metadata GC Threshold)指这是一次full gc,括号里是gc的原因, PSYoungGen是年轻代的GC,ParOldGen是老年代的GC,Metaspace是元空间的GC

3、 6160K->0K(141824K),这三个数字分别对应GC之前占用年轻代的大小,GC之后年轻代占用,以及整个年轻代的大小。

4、112K->6056K(95744K),这三个数字分别对应GC之前占用老年代的大小,GC之后老年代占用,以及整个老年代的大小。

5、6272K->6056K(237568K),这三个数字分别对应GC之前占用堆内存的大小,GC之后堆内存占用,以及整个堆内存的大小。

6、20516K->20516K(1069056K),这三个数字分别对应GC之前占用元空间内存的大小,GC之后元空间内存占用,以及整个元空间内存的大小。

7、0.0209707是该时间点GC总耗费时间。

从日志可以发现几次fullgc都是由于元空间不够导致的,所以我们可以将元空间调大点

1
2
3
java -jar -Xloggc:./gc-adjust-%t.log -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps  
-XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
microservice-eureka-server.jar

​ 调整完我们再看下gc日志发现已经没有因为元空间不够导致的fullgc了

​ 对于CMS和G1收集器的日志会有一点不一样,也可以试着打印下对应的gc日志分析下,可以发现gc日志里面的gc步骤跟我们之前讲过的步骤是类似的

CMS-GC日志

image-20220314211343508

G1-GC日志

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
/**
* -Xloggc:d:/gc-g1-%t.log -Xms10M -Xmx10M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
* -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause
* -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
* -XX:+UseG1GC -XX:MaxGCPauseMillis=2
*/
public class HeapTest {
public static void main(String[] args) throws InterruptedException {
ArrayList<SoftReference<byte[]>> heapTests = new ArrayList<>();

while (true) {
HeapTest heapTest = new HeapTest();
heapTest.func();
Thread.sleep(10);
heapTests.add(new SoftReference<>(heapTest.funcA()));
}
}
public void func(){
byte[] a = new byte[1024 * 100]; //100KB
}
public byte[] funcA(){
byte[] a = new byte[1024 * 100]; //100KB
return a;
}
}

日志太长就不放了

image-20220314215033980

上面的这些参数,能够帮我们查看分析GC的垃圾收集情况。但是如果GC日志很多很多,成千上万行。就算你一目十行,看完了,脑子也是一片空白。所以我们可以借助一些功能来帮助我们分析,这里推荐一个gceasy(https://gceasy.io),可以上传gc文件,然后他会利用可视化的界面来展现GC情况。具体下图所示

clipboard-1647265871233

上图我们可以看到年轻代,老年代,以及永久代的内存分配,和最大使用情况。

0

​ 上图我们可以看到堆内存在GC之前和之后的变化,以及其他信息。

这个工具还提供基于机器学习的JVM智能优化建议,当然现在这个功能需要付费0

clipboard-1647265871348

JVM参数汇总查看命令

java -XX:+PrintFlagsInitial 表示打印出所有参数选项的默认值

java -XX:+PrintFlagsFinal 表示打印出所有参数选项在运行程序时生效的值

JVM调优思路

1、内存管理优化

​ 1、合适的堆大小、新生代和老年代的比例(如果系统大部分对象朝生夕死,则适当增大年轻代,以减少短生命周期对象进入老年代的频率),尽量让Young GC不触发动态年龄判断,存货对象留在老年代

​ 2、大对象直接进入老年代

2、选择合适的垃圾收集器

​ 1、根据应用自身要求和特性选择合适的垃圾收集器,对响应时间要求高,选择CMS;对于大内存的应用,对停顿时间有严格限制的,选择G1或者ZGC;对于简单程序,程序默认的parallel即可

3、JIT编译优化+开启线程逃逸分析

​ 1、逃逸分析有利于栈上分配对象

​ 2、调整 JIT 编译的阈值:通过 -XX:CompileThreshold 等参数调整方法被编译为本地代码的调用次数阈值,以提高热点方法的执行效率。

4、合理设置线程池大小

​ 1、根据系统性能合理的设置各类线程池的大小,比如数据库连接池,redis连接池、自定义线程池、kafka消费并发数等,如果设置过大或者过载,会导致JVM在触发垃圾回收时需要花费时间来处理处理线程上下文,消耗大量时间来完成标记过程GCroot,进而导致停顿时间边长。

5、完善的监测告警和分析优化

Class常量池与运行时常量池

Class常量池

​ Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是**常量池(constant pool table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)**。

一个class文件的16进制大体结构如下图:

image-20220314221534785

对应的含义如下,细节可以查下oracle官方文档

image-20220314221600707

当然我们一般不会去人工解析这种16进制的字节码文件,我们一般可以通过javap命令生成更可读的JVM字节码指令文件

1
javap -v Math.class

image-20220314221951108

常量池中主要存放两大类常量:字面量和符号引用

字面量

​ 由字母、数字构成的字符串或者数值常量,字面量只可以右值出现,所谓右值是指等号右边的值,如:int a=1 这里的a为左值,1为右值。在这个例子中1就是字面量,如

1
2
3
4
int a = 1;
int b = 2;
String c = "abcdefg";
String d = "abcdefg";
符号引用

​ 符号应用是编译原理的概念、相对于直接引用而言,主要包含以下3类常量

  • 类和接口的全限定名
  • 字段名称和描述符
  • 方法名和方法描述符

​ 上面的a,b就是字段名称,就是一种符号引用,还有Math类常量池里的 Lcom/tuling/jvm/Math 是类的全限定名,main和compute是方法名称,()是一种UTF8格式的描述符,这些都是符号引用。
这些常量池现在是静态信息,只有到运行时被加载到内存后,这些符号才有对应的内存地址信息,这些常量池一旦被装入内存就变成运行时常量池。

​ 符号应用在类加载(把静态变量、静态方法、静态类变为直接引用——静态链接)或者程序运行时(其他情况——动态链接)变为动态引用。所谓引用就是具体符号应用在内存中实际的内存地址或者句柄。例如,compute()这个符号引用在运行时就会被转变为compute()方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。

字符串常量池(jdk8后字符串常量池只在堆中,运行时常量池在元空间)

字符串常量池的设计思想

  1. 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能
  2. JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
  • 为字符串开辟一个字符串常量池,类似于缓存区
  • 创建字符串常量时,首先查询字符串常量池是否存在该字符串
  • 存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中

三种字符串操作

1、直接复制字符串

1
String s = "zhuge";  // s指向常量池中的引用

这种方式创建的字符串对象,只会在常量池中。因为有”zhuge”这个字面量,创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象
如果有,则直接返回该对象在常量池中的引用;
如果没有,则会在常量池中创建一个新对象,再返回引用。

2、new String()

1
String s1 = new String("zhuge");  // s1指向内存中的对象引用

这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用

步骤大致如下:

因为有”zhuge”这个字面量,所以会先检查字符串常量池中是否存在字符串”zhuge”

不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象”zhuge”;

存在的话,就直接去堆内存中创建一个字符串对象”zhuge”;

最后,将内存中的引用返回。

3、intern()方法

1
2
3
String s1 = new String("zhuge");   
String s2 = s1.intern();
System.out.println(s1 == s2); //false

通过new操作符创建的字符串对象不指向字符串常量池中的任何对象,但是可以通过使用字符串的intern()方法来指向其 中的某一个。java.lang.String.intern()返回一个常量池里面的字符串,就是一个在字符串常量池中有了一个入口。如果 以前没有在字符串常量池中,那么它就会被添加到里面

String常量池问题的几个例子

示例1:

1
2
3
4
5
String s0="zhuge";
String s1="zhuge";
String s2="zhu" + "ge";
System.out.println( s0==s1 ); //true
System.out.println( s0==s2 ); //true

分析:因为例子中的 s0和s1中的”zhuge”都是字符串常量,它们在编译期就被确定了,所以s0s1为true;而”zhu”和”ge”也都是字符串常量,当一个字 符串由多个字符串常量连接而成时,它自己肯定也是字符串常量,所以s2也同样在编译期就被优化为一个字符串常量”zhuge”,所以s2也是常量池中” zhuge”的一个引用。所以我们得出s0 == s1 == s2;

示例2:

1
2
3
4
5
6
String s0="zhuge";
String s1=new String("zhuge");
String s2="zhu" + new String("ge");
System.out.println( s0==s1 );  // false
System.out.println( s0==s2 );  // false
System.out.println( s1==s2 );  // false

分析:用new String() 创建的字符串不是常量,不能在编译期就确定,所以new String() 创建的字符串不放入常量池中,它们有自己的地址空间。

s0还是常量池 中”zhuge”的引用,s1因为无法在编译期确定,所以是运行时创建的新对象”zhuge”的引用,s2因为有后半部分 new String(”ge”)所以也无法在编译期确定,所以也是一个新创建对象”zhuge”的引用;明白了这些也就知道为何得出此结果了。

示例3:

1
2
3
4
5
6
7
8
9
10
11
String a = "a1";
String b = "a" + 1;
System.out.println(a == b); // true

String a = "atrue";
String b = "a" + "true";
System.out.println(a == b); // true

String a = "a3.4";
String b = "a" + 3.4;
System.out.println(a == b); // true

分析:JVM对于字符串常量的”+”号连接,将在程序编译期,JVM就将常量字符串的”+”连接优化为连接后的值,拿”a” + 1来说,经编译器优化后在class中就已经是a1。在编译期其字符串常量的值就确定下来,故上面程序最终的结果都为true。

示例4:

1
2
3
4
String a = "ab";
String bb = "b";
String b = "a" + bb;
System.out.println(a == b); // false

分析:JVM对于字符串引用,由于在字符串的”+”连接中,有字符串引用存在,而引用的值在程序编译期是无法确定的,即”a” + bb无法被编译器优化,只有在程序运行期来动态分配并将连接后的新地址赋给b。所以上面程序的结果也就为false。

示例5:

1
2
3
4
5
String a = "ab";
final String bb = "b";
String b = "a" + bb;

System.out.println(a == b); // true

分析:和示例4中唯一不同的是bb字符串加了final修饰,对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。所以此时的”a” + bb和”a” + “b”效果是一样的。故上面程序的结果为true。

示例6:

1
2
3
4
5
6
7
8
String a = "ab";
final String bb = getBB();
String b = "a" + bb;
System.out.println(a == b); // false
private static String getBB()
{
return "b";
}

分析:JVM对于字符串引用bb,它的值在编译期无法确定,只有在程序运行期调用方法后,将方法的返回值和”a”来动态连接并分配地址为b,故上面 程序的结果为false。

关于String是不可变的

通过上面例子可以得出得知

1
2
3
4
5
String  s  =  "a" + "b" + "c";  //就等价于String s = "abc";
String  a  =  "a";
String  b  =  "b";
String  c  =  "c";
String  s1  =   a  +  b  +  c;

s1 这个就不一样了,可以通过观察其JVM指令码发现s1的”+”操作会变成如下操作:

1
2
3
StringBuilder temp = new StringBuilder();
temp.append(a).append(b).append(c);
String s = temp.toString();
1
2
3
4
5
6
String str1 = "abc"; 
String str2 = "abc";
String str3 = "abc";
String str4 = new String("abc");
String str5 = new String("abc");
String str5 = new String("abc");

image-20220315091119340

面试题:String str4 = new String(“abc”) 创建多少个对象?

  1. 在常量池中查找是否有“abc”对象

有则返回对应的引用实例

没有则在常量池中创建对应的实例对象

  1. 在堆中 new 一个 String(“abc”) 对象

    3.将对象地址赋值给str4,创建一个引用

所以,常量池中没有“abc”字面量则创建两个对象,否则创建一个对象,以及创建一个引用

根据字面量,往往会提出这样的变式题:

String str1 = new String(“A”+”B”) ; 会创建多少个对象?

String str2 = new String(“ABC”) + “ABC” ; 会创建多少个对象?

str1:

字符串常量池:”AB” : 1个

堆:new String(“AB”) :1个

引用: str1 :1个

总共 : 3个

str2 :

字符串常量池:”ABC” : 1个

堆:new String(“ABC”) 、”ABCABC”:2个

引用: str2 :1个

总共 : 4个

八种基本类型的包装类和对象池

java中基本类型的包装类的大部分都实现了常量池技术(严格来说应该叫对象池,在堆上),这些类是

Byte

Short

Integer

Long

Character

Boolean

另外两种浮点数类型的包装类则没有实现。

Byte,Short,Integer,Long,Character这5种整型的包装类也只是在对应值-128~127时才可使用对象池,也即对象不负责创建和管理范围外的这些类的对象。因为一般这种比较小的数用到的概率相对较大。

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
public class Test {
public static void main(String[] args) {
//5种整形的包装类Byte,Short,Integer,Long,Character的对象,
//在值小于127时可以使用对象池
Integer i1 = 127; //这种调用底层实际是执行的Integer.valueOf(127),里面用到了IntegerCache对象池
Integer i2 = 127;
System.out.println(i1 == i2);//输出true

//值大于127时,不会从对象池中取对象
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);//输出false

//用new关键词新生成对象不会使用对象池
Integer i5 = new Integer(127);
Integer i6 = new Integer(127);
System.out.println(i5 == i6);//输出false

//Boolean类也实现了对象池技术
Boolean bool1 = true;
Boolean bool2 = true;
System.out.println(bool1 == bool2);//输出true

//浮点类型的包装类没有实现对象池技术
Double d1 = 1.0;
Double d2 = 1.0;
System.out.println(d1 == d2);//输出false
}
}

安全点与安全区域

​ 安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比 如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。 这些特定的安全点位置主要有以下几种:

  1. 方法返回之前
  2. 调用某个方法之后
  3. 抛出异常的位置
  4. 循环的末尾

安全区域又是什么? Safe Point 是对正在执行的线程设定的。 如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。 因此 JVM 引入了 Safe Region。 Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。 线程在进入 Safe Region 的时候先标记自己已进入了 Safe Region,等到被唤醒时准备离开 Safe Region 时,先检查能 否离开,如果 GC 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。