【JVM】JVM 调优实战案例

本文首先介绍内存泄漏的常见案例并详细分析四种OOM案例下的排查方案,然后介绍一些JVM的调优实战案例,并演示JVM常见监控与诊断工具的使用。

Java 内存泄露的 9 种情况

严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。

可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多种内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。

image-20200712195158470

如下图,当Y生命周期结束的时候,X依然引用着Y,这时候,垃圾回收器是不会回收对象Y的;如果对象X还引用着生命周期比较短的A、B、C,这样就可能造成大量无用的对象不能被回收,进而占据了内存资源,造成内存泄漏,直到内存溢出。

image-20211019200416595

申请了内存用完了不释放,比如一共有1024M的内存,分配了512M的内存一直不回收,那么可以用的内存只有512M了,仿佛泄露掉了一部分。

内存泄漏的常见情况

  • 代码中有死循环
  • 使用了静态集合类,在其内添加了过多的对象,一直无法被回收
  • 使用了过多的单例对象,其内引用了其他外部对象,导致这些对象一直无法被回收
  • 各种连接忘记关闭
  • 变量作用域不合理
  • 缓存泄漏:缓存数据过多,无法及时清理

解决方案:

  • 尽量减少静态集合类的使用,或者在不使用集合内的对象后,及时设置为 null 进行清理
  • 减少单例对象使用,尽量不引用外部对象
  • 及时关闭连接
  • 尽量使用局部变量,减少使用没必要的类成员变量
  • 缓存数据使用 WeakHashMap,在其内对象失去其他引用后就自动清理

静态集合类

静态集合类,如HashMapLinkedList等等。如果这些容器为静态的,那么它们的生命周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

典型例子:在静态数组中不断添加对象,由于静态对象不会随着实例对象的回收而回收,因此其添加的对象就会造成内存泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MemoryLeak {
static List list = new ArrayList();

/**
* 尽管这个局部变量Object生命周期非常短
* 但是它被生命周期非常长的静态列表引用
* 所以不会被GC回收 发生内存溢出
*/
public void oomTests(){
Object obj = new Object(); //局部变量
list.add(obj);
}
}

类卸载的条件非常苛刻,这个静态列表生命周期基本与JVM一样长

单例模式

单例模式,和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和 JVM 的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。

饿汉式单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static final Singleton INSTANCE = new Singleton();

private Singleton(){
if (INSTANCE!=null){
throw new RuntimeException("not create instance");
}
}

public static Singleton getInstance(){
return INSTANCE;
}
}

饿汉式的单例模式也是被静态变量引用,即使不需要使用这个单例对象了,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
public class InnerClassTest {
class InnerClass {
}

public InnerClass getInnerInstance() {
return this.new InnerClass();
}

public static void main(String[] args) {
InnerClass innerInstance = null;

{
InnerClassTest innerClassTest = new InnerClassTest();
innerInstance = innerClassTest.getInnerInstance();

System.out.println("========= 外部实例对象内存布局 =========");
System.out.println(ClassLayout.parseInstance(innerClassTest).toPrintable());
System.out.println("========= 内部实例对象内存布局 =========");
System.out.println(ClassLayout.parseInstance(innerInstance).toPrintable());
}

// ...
}
}

当调用外部类实例方法通过外部实例对象返回一个内部实例对象时(调用代码中的getInnerInstance()方法),外部实例对象不需要使用了,但内部实例对象被长期使用,会导致这个外部实例对象生命周期变长。

因为内部实例对象隐藏了一个指针指向(引用)创建它的外部实例对象

img

各种连接,如数据库连接、网络连接和 IO 连接等

在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close()方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对ConnectionStatementResultSet不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
try {
Connection conn =null;
Class.forName("com.mysql.jdbc.Driver");
conn =DriverManager.getConnection("url","","");
Statement stmt =conn.createStatement();
ResultSet rs =stmt.executeQuery("....");
} catch(Exception e){
// 异常日志
} finally {
// 1.关闭结果集 Statement
// 2.关闭声明的对象 ResultSet
// 3.关闭连接 Connection
}
}

变量不合理的作用域

一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生。

1
2
3
4
5
6
7
8
9
public class UsingRandom {
private String msg;
public void receiveMsg(){
readFromNet(); // 从网络中接受数据保存到msg中
saveDB(); // 把msg保存到数据库中

// 应该在使用完后手动 msg = null;
}
}

如上面这个伪代码,通过readFromNet()方法把接受的消息保存在变量msg中,然后调用saveDB()方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。实际上这个msg变量可以放在receiveMsg()方法内部,当方法使用完,那么msg的生命周期也就结束,此时就可以回收了。还有一种方法,在使用完msg后,把msg设置为null,这样垃圾回收器也会回收msg的内存空间。

改变哈希值

改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains()方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。

这也是 String 为什么被设置成了不可变类型,我们可以放心地把 String 存入 HashSet,或者把String 当做 HashMap 的 key 值;

当我们想把自己定义的类保存到散列表的时候,需要保证对象的 hashCode 不可变。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class ChangeHashCode {
public static void main(String[] args) {
HashSet set = new HashSet();
Person p1 = new Person(1001, "AA");
Person p2 = new Person(1002, "BB");

set.add(p1);
set.add(p2);

p1.name = "CC"; // 导致了内存的泄漏
set.remove(p1); // 删除失败

System.out.println(set);

set.add(new Person(1001, "CC"));
System.out.println(set);

set.add(new Person(1001, "AA"));
System.out.println(set);

}
}

class Person {
int id;
String name;

public Person(int id, String name) {
this.id = id;
this.name = name;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;

Person person = (Person) o;

if (id != person.id) return false;
return name != null ? name.equals(person.name) : person.name == null;
}

@Override
public int hashCode() {
int result = id;
result = 31 * result + (name != null ? name.hashCode() : 0);
return result;
}

@Override
public String toString() {
return "Person{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
}

所以说对象当作Key存入散列表时,该对象最好是逻辑不可变对象,不能在外界改变它的关键域,从而无法改变哈希值:

image-20210520211835353

将关键域设置为final,只能在实例代码块中初始化或构造器中。如果关键域是引用类型,可以用final修饰后,对外不提供改变该引用关键域的方法,从而让外界无法修改引用关键域中的值 (如同String类型,所以String常常用来当作散列表的Key)

缓存泄露

内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘。比如:之前项目在一次上线的时候,应用启动奇慢直到夯死,就是因为代码中会加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据。

对于这个问题,可以使用WeakHashMap代表缓存(其内的 Entry是弱引用),此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值。

WeakHashMap 内部的Entry是弱引用,当它的Key不再使用时,下次垃圾回收就会回收掉,不会发生内存泄漏

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
44
45
46
47
48
49
50
51
52
53
54
55
56
public class MapTest {
static Map wMap = new WeakHashMap();
static Map map = new HashMap();

public static void main(String[] args) {
init();
testWeakHashMap();
testHashMap();
}

public static void init() {
String ref1 = new String("obejct1");
String ref2 = new String("obejct2");
String ref3 = new String("obejct3");
String ref4 = new String("obejct4");
wMap.put(ref1, "cacheObject1");
wMap.put(ref2, "cacheObject2");
map.put(ref3, "cacheObject3");
map.put(ref4, "cacheObject4");
System.out.println("String引用ref1,ref2,ref3,ref4 消失");
}

public static void testWeakHashMap() {
System.out.println("WeakHashMap GC之前");
for (Object o : wMap.entrySet()) {
System.out.println(o);
}
try {
System.gc();
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("WeakHashMap GC之后");
for (Object o : wMap.entrySet()) {
System.out.println(o);
}
}

public static void testHashMap() {
System.out.println("HashMap GC之前");
for (Object o : map.entrySet()) {
System.out.println(o);
}
try {
System.gc();
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("HashMap GC之后");
for (Object o : map.entrySet()) {
System.out.println(o);
}
}
}

上面代码和图示主演演示WeakHashMap如何自动释放缓存对象,当init()函数执行完成后,局部变量字符串引用weakd1,weakd2,d1,d2都会消失,此时只有静态map中保存着对字符串对象的引用,可以看到,调用gc之后,HashMap的没有被回收,而WeakHashMap里面的缓存被回收了。

线程池中的 ThreadLocal

监听器和其他回调

如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。

需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为WeakHashMap中的键。

内存泄露案例分析

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 Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) { // 入栈
ensureCapacity();
elements[size++] = e;
}

public Object pop() { // 出栈
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}

private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}

上述程序并没有明显的错误,但是这段程序有一个内存泄漏,随着GC活动的增加,或者内存占用的不断增加,程序性能的降低就会表现出来,严重时可导致内存泄漏,但是这种失败情况相对较少。

代码的主要问题在pop()函数,下面通过这张图示展现。假设这个栈一直增长,增长后如下图所示:

image-20211019202508227

当进行大量的pop()操作时,由于引用未进行置空,gc是不会释放的,如下图所示:

image-20211019202528238

从上图中看以看出,如果栈先增长,再收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些队象,他们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽。

将代码中的pop()方法变成如下方法:

1
2
3
4
5
6
7
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}

一旦引用过期,清空这些引用,将引用置空。

image-20211019202604985

动态数组ArrayListremove()操作会改变size的同时将删除位置置空,从而不再引用元素,避免内存泄漏:

img

OOM 出现的原因

主要分为四类:

  • 堆溢出:java.lang.OutOfMemoryError: Java heap space,原因为对象数量过多(最常见)
  • 元空间溢出: java.lang.OutOfMemoryError: Metaspace,原因为基于反射原理动态加载过多的类
  • 超出 GC 开销限制:java.lang.OutOfMemoryError: GC overhead limit exceeded,原因为超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。本质是一个预判性的异常,抛出该异常时系统没有真正的内存溢出,而是预判到马上就要溢出了
  • 线程栈溢出:java.lang.OutOfMemoryError : unable to create new native Thread,原因为线程数量超出限制或超出操作系统上限

如果某一个线程栈所占用的内存空间大于了其设置值,报的是 StackOverflowError

OOM 案例一:堆溢出

案例模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class MemoryTestController {
@Autowired
private UserService userSevice;

/**
* 案例1:模拟线上环境OOM
*/
@RequestMapping("/add")
public void addObject(){
System.err.println("add" + userSevice);
ArrayList<User> people = new ArrayList<>();
while (true){
people.add(new User());
}
}
}

JVM 参数:

1
2
3
-XX:+PrintGCDetails -XX:MetaspaceSize=64m -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=heap/heapdump.hprof -XX:+PrintGCDateStamps
-Xms50M -Xmx50M -Xloggc:log/gc-oomHeap.log

运行结果:

1
java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) ~[na:1.8.0_131] at java.util.Arrays.copyOf(Arrays.java:3181) ~[na:1.8.0_131] at java.util.ArrayList.grow(ArrayList.java:261) ~[na:1.8.0_131] at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235) ~[na:1.8.0_131] at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227) ~[na:1.8.0_131] 运行程序得到 heapdump.hprof 文件。

报错信息:java.lang.OutOfMemoryError: Java heap space

原因及解决方案

原因:

  • 代码中可能存在大对象分配
  • 可能存在内存泄漏,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。

解决方法:

  • 检查是否存在大对象的分配,最有可能的是大数组分配
  • 通过jmap命令,把堆内存dump下来,使用MAT等工具分析一下,检查是否存在内存泄漏的问题
  • 如果没有找到明显的内存泄漏,使用 -Xmx 加大堆内存
  • 还有一点容易被忽略,检查是否有大量的自定义的 Finalizable 对象(这些自定义对象很可能会“复活”),也有可能是框架内部提供的,考虑其存在的必要性

JVisual VM 排查

通过jvisualvm工具查看,占用最多实例的类是哪个,这样就可以定位到我们的问题所在。

排查流程:

image-20211021202816470

image-20211021202917101

image-20211021203044271

分析对象情况,发现有大量的 User 对象:

image-20211021203309218

MAT 排查

使用MAT工具查看,能找到对应的线程及相应线程中对应实例的位置和代码:

image-20211021203949345

image-20211021204650402

image-20211021204534884

image-20211021205142061

GCeasy 查看

image-20211022190629659

可以看到每次 Full GC 后,堆空间大小都基本不变,并且也接近上限,说明有大量的内存泄漏。

OOM 案例二:元空间溢出

案例模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 案例2:模拟元空间OOM溢出
*/
@RequestMapping("/metaSpaceOom")
public void metaSpaceOom(){
ClassLoadingMXBean classLoadingMXBean = ManagementFactory.getClassLoadingMXBean();
while (true){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(User.class);
// enhancer.setUseCache(false);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> {
System.out.println("我是加强类,输出print之前的加强方法");
return methodProxy.invokeSuper(o,objects);
});
User user = (User)enhancer.create();
user.print();
System.out.println(user.getClass());
System.out.println("totalClass:" + classLoadingMXBean.getTotalLoadedClassCount());
System.out.println("activeClass:" + classLoadingMXBean.getLoadedClassCount());
System.out.println("unloadedClass:" + classLoadingMXBean.getUnloadedClassCount());
}
}

JVM 参数:

1
2
3
4
-Xms60M  -Xmx60M -XX:MetaspaceSize=60m -XX:MaxMetaspaceSize=60m -Xss512K
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdumpMeta.hprof
-XX:SurvivorRatio=8 -XX:+TraceClassLoading -XX:+TraceClassUnloading
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:log/gc-oomMeta.log

报错信息:java.lang.OutOfMemoryError: Metaspace

原因及解决方案

JDK 8 后,元空间替换了永久代,元空间使用的是本地内存,原因:

  • 运行期间生成了大量的代理类,导致方法区被撑爆,无法卸载
  • 应用长时间运行,没有重启
  • 元空间内存设置过小

解决方法:

  • 检查是否永久代空间或者元空间设置的过小
  • 检查代码中是否存在大量的反射操作
  • dump之后通过MAT检查是否存在大量由于反射生成的代理类

本案例的具体解决方案:

1
enhancer.setUseCache(false); // 开启缓存

命令行查看 GC 情况

可以使用 jstat 命令查看堆空间和元空间的内存情况:

1
jstat -gc pid 1000 10

每隔 1000 ms 打印一次 GC 情况,共打印 10 次

打印结果:

image-20211021213540629

可以看到元空间几乎被占满了,并且出现了大量的 Full GC 。那么我们接下来分析到底是什么数据占用了大量的方法区。

JVisual VM 排查

通过jvisualvm工具查看元空间的内存情况,请求访问前:

image-20211021213006851

访问该请求后,动态加载大量类,可以看到元空间占用明显增加,直至造成OOM:

image-20211021213232807

接着查看方法栈情况,找到自己包下的方法栈:

image-20211021214225726

根据方法栈信息,找到代码中的相应位置,是因为创建了大量的类:

1
User user = (User)enhancer.create();

MAT 排查

首先我们先确定是哪里的代码发生了问题,首先可以通过线程来确定,因为在实际生产环境中,有时候是无法确定是哪块代码引起的OOM,那么我们就需要先定位问题线程,然后定位代码,如下图所示:

image-20211022115128142

从上图可以看到出现异常的方法栈位置在自己代码的第50行。

定位到代码以后,发现有使用到cglib动态代理,那么我们猜想一下问题是不是由于产生了很多代理类,接下来,我们可以通过包看一下我们的类加载情况:

image-20211022094909679

这里发现Method类的实例非常多,查看with outgoing references发现了很多的User类在调用相关的方法。由于我们的代码是代理的User类,所以我们直接打开User类所在的包,打开如下图所示:可以看到确实加载了很多的代理类(User$$EnhancerByCGLIB):

image-20211022095110080

image-20211022095526532

最终找到了原因:动态加载了大量的类导致元空间OOM

OOM 案例三:GC overhead limit exceeded

案例模拟

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
/**
* 案例3:测试 GC overhead limit exceeded
*/
public class OOMTest {
public static void main(String[] args) {
test1();
//test2();
}

public static void test1() {
int i = 0;
List<String> list = new ArrayList<>();
try {
while (true) {
list.add(UUID.randomUUID().toString().intern());
i++;
}
} catch (Throwable e) {
System.out.println("************i: " + i);
e.printStackTrace();
throw e;
}
}

public static void test2() {
String str = "";
Integer i = 1;
try {
while (true) {
i++;
str += UUID.randomUUID();
}
} catch (Throwable e) {
System.out.println("************i: " + i);
e.printStackTrace();
throw e;
}
}
}

JVM 配置:

1
2
3
4
-XX:+PrintGCDetails  -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=heap/dumpExceeded.hprof
-XX:+PrintGCDateStamps -Xms10M -Xmx10M
-Xloggc:log/gc-oomExceeded.log

报错信息:java.lang.OutOfMemoryError: GC overhead limit exceeded

GC 日志:

1
2
3
4
5
[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7110K->7095K(7168K)] 9158K->9143K(9728K), 
[Metaspace: 3177K->3177K(1056768K)], 0.0479640 secs] [Times: user=0.23 sys=0.01, real=0.05 secs]
java.lang.OutOfMemoryError: GC overhead limit exceeded
[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7114K->7096K(7168K)] 9162K->9144K(9728K),
[Metaspace: 3198K->3198K(1056768K)], 0.0408506 secs] [Times: user=0.22 sys=0.01, real=0.04 secs]

通过查看GC日志可以发现,系统在频繁性的做Full GC,但是却没有回收掉多少空间,那么引起的原因可能是因为内存不足,也可能是存在内存泄漏的情况,接下来我们要根据堆dump文件来具体分析。

原因与解决方案

原因分析:这个是 JDK 6 新加的错误类型,一般都是堆太小导致的。Sun 官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。本质是一个预判性的异常,抛出该异常时系统没有真正的内存溢出,而是预判到马上就要溢出了

第一段代码:运行期间将内容放入常量池的典型案例 。intern() 方法:

  • 如果字符串常量池里面已经包含了等于字符串X的字符串,那么就返回常量池中这个字符串的引用;
  • 如果常量池中不存在,那么就会把当前字符串添加到常量池并返回这个字符串的引用

第二段代码:不停的追加字符串 str 你可能会疑惑,看似 demo 也没有差太多,为什么第二个没有报 GC overhead limit exceeded 呢?以上两个demo的区别在于:

  • Java heap space 的 demo 每次都能回收大部分的对象(中间产生的UUID),只不过有一个对象是无法回收的,慢慢长大(字符串长度越来越大),直到内存溢出
  • GC overhead limit exceeded 的 demo 由于每个字符串都在被list引用,所以无法回收,很快就用完内存,触发不断回收的机制。

解决方案:

根据业务来修改是否需要死循环。

  • 检查项目中是否有大量的死循环或有使用大内存的代码,优化代码。
  • 添加参数 -XX:-UseGCOverheadLimit 禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,最终出现 java.lang.OutOfMemoryError: Java heap space
  • dump内存,检查是否存在内存泄漏,如果没有,加大内存

JVisual VM 排查

定位到了具体的线程中具体出现问题的代码的位置,进而进行优化即可

image-20211022104627351

MAT 排查

image-20211022104727014

image-20211022104828175

上图中找到我们自己线程出现异常的方法栈位置,在26行,在代码中看到是因为进行了死循环,不停的往 ArrayList存放字符串常量(JDK1.8以后,字符串常量池移到了堆中存储,所以最终导致内存不足发生了OOM)

同时从上图中可以看到我们自己线程下的局部变量 java.util.ArrayList 的深堆对象占用大量内存,分析其 outgoing reference,发现其内存储了大量的 String 对象:

image-20211022104950763

接着分析dump文件直方图,打开Histogram,可以看到,String类型的字符串占用了大概8M的空间,几乎把堆占满,但是还没有占满,所以这也符合Sun官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常,本质是一个预判性的异常,抛出该异常时系统没有真正的内存溢出。

image-20211022105050210

OOM 案例四:线程栈溢出

案例模拟

不添加 -Xss 参数

在主程序中,不断创建新的线程,直到报异常:java.lang.OutOfMemoryError : unable to create new native Thread。同时操作系统失去响应,需要重新启动。

出现这种异常,基本上都是创建了大量的线程导致的,超出了系统的资源上限。

1
2
3
4
5
i = 15241 
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
at TestNativeOutOfMemoryError.main(TestNativeOutOfMemoryError.java:9)

添加 -Xss 参数,设置每个线程栈的大小

在 Linux 下运行 Java 程序,并添加 JVM 参数设置线程栈大小:

1
java -Xss512k TestNativeOutOfMemory

发现同样在 15241 左右次迭代时出现 OOM,似乎这个参数不起作用?

原因

通过 -Xss 可以设置每个线程栈大小的容量。

  • JDK 5.0 以后每个线程堆栈大小为1M,
  • JDK 5.0 以前每个线程堆栈大小为256K。

如果一个线程栈的大小超出 -Xss 设置的值,就会报 SOF:Stack Over Flow

正常情况下,在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。 能创建的线程数的具体计算公式如下:

1
(MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize) = Number of threads
  • MaxProcessMemory:进程可寻址的最大空间
  • JVMMemory:JVM内存
  • ReservedOsMemory:保留的操作系统内存
  • ThreadStackSize:线程栈的大小

在Java语言里, 当你创建一个线程的时候,虚拟机会在JVM内存创建一个Thread对象同时创建一个操作系统线程,而这个系统线程的内存用的不是JVMMemory,而是系统中剩下的内存(MaxProcessMemory - JVMMemory - ReservedOsMemory)

由公式得出结论:你给JVM内存越多,那么你能创建的线程越少,越容易发生java.lang.OutOfMemoryError: unable to create new native thread。 综上,在生产环境下如果需要更多的线程数量,建议使用64位操作系统,如果必须使用32位操作系统,可以通过调整-Xss的大小来控制线程数量。

而之所以出现上面设不设置参数,结果都一样的原因是:64 位操作系统的MaxProcessMemory值非常非常大,可以理解为正无穷,所以就算分母除以的参数是默认的1M还是512K,算出来的线程数都特别特别多大,早就已经超出了操作系统的上限

因为受到系统上限的影响,设不设置,可以出关键的线程数都达到了上限 15000+。因此两个案例启示都是在超出操作系统资源上限时OOM的,并没有达到这个公式里的理论值。

补充

线程总数也受到系统空闲内存和操作系统的限制,检查是否该系统下有此限制:

  • /proc/sys/kernel/pid_max:系统最大pid值,在大型系统里可适当调大
  • /proc/sys/kernel/threads-max:系统允许的最大线程数
  • maxuserprocess(ulimit -u):系统限制某用户下最多可以运行多少进程或线程
  • /proc/sys/vm/max_map_count:max_map_count文件包含限制一个进程可以拥有的VMA(虚拟内存区域)的数量。虚拟内存区域是一个连续的虚拟地址空间区域。

在进程的生命周期中,每当程序尝试在内存中映射文件,链接到共享内存段,或者分配堆空间的时候,这些区域将被创建。调优这个值将限制进程可拥有VMA的数量。限制一个进程拥有VMA的总数可能导致应用程序出错,因为当进程达到了VMA上线但又只能释放少量的内存给其他的内核进程使用时,操作系统会抛出内存不足的错误。如果你的操作系统在NORMAL区域仅占用少量的内存,那么调低这个值可以帮助释放内存给内核用。

JVM 调优概述

为什么要调优?

  • 防止出现 OOM,进行 JVM 规划和调优(例如在上线前先压测)
  • 解决程序运行中各种 OOM(运行时及时排查出OOM原因)
  • 减少 Full GC 出现的频率,解决运行慢、卡顿问题

总结:预防 OOM,减少 Full GC。

调优的大方向

  • 合理地编写代码
  • 充分并合理的使用硬件资源
  • 合理地进行JVM调优

调优监控的依据

  • 运行日志
  • 异常堆栈
  • GC日志
  • 线程快照
  • 堆转储快照

性能优化的步骤

  • 第1步:熟悉业务场景
  • 第2步(发现问题):性能监控
    • GC 频繁
    • CPUload过高
    • OOM
    • 内存泄漏(可能导致OOM)
    • 死锁(可能导致CPU负载过高)
    • 程序响应时间较长(说明频繁GC)
  • 第3步(排查问题):性能分析
    • 打印GC日志,通过GCviewer或者 http://gceasy.io来分析日志信息
    • 灵活运用命令行工具,jstack,jmap,jinfo等
    • dump出堆文件,使用内存分析工具MAT分析文件
    • 使用阿里Arthas,或jconsole,JVisualVM来实时查看JVM状态
    • jstack查看堆栈信息
  • 第4步(解决问题):性能调优
    • 适当增加内存,根据业务背景选择合适的垃圾回收器
    • 优化代码,控制内存使用
    • 增加机器,分散节点压力
    • 合理设置线程池线程数量
    • 使用中间件提高程序效率,比如缓存,消息队列等

性能评价/测试指标

  • 停顿时间(或响应时间)
  • 吞吐量
  • 并发数
  • 内存占用
  • 相互间的关系

调优案例一:调整堆大小提升服务的吞吐量

本案例演示在不同堆大小的配置下,对比服务的吞吐量与GC情况。

首先需要配置 Linux 下 Tomcat 的堆内存大小。

生产环境下,Tomcat并不建议直接在catalina.sh里配置变量,而是写在与catalina同级目录(bin目录)下的setenv.sh里。 所以如果我们想要修改jvm的内存配置,那么我们就需要修改setenv.sh文件(默认没有,需新建一个setenv.sh)。

初始配置:堆内存较小

初始配置(堆内存设置较小):

1
2
3
4
5
6
7
8
export CATALINA_OPTS="$CATALINA_OPTS -Xms20m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:SurvivorRatio=8"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx20m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"

-Xms 和 -Xmx 两个参数设置相同的值,目的是为了能够在每次GC后不需要再重新计算堆区要分配的大小,从而提高性能。

如果二者设置不相同,则每次GC后,会根据当前GC的效果动态调整堆区的大小:回收效果好 -> 减小堆区大小;回收效果差 -> 增大堆区大小


使用 JMeter 进行压测,发送50 * 1000个请求:观察吞吐量与GC情况。

吞吐量:

image-20211023100312067

使用 jstat -gc pid 1000 5 查看压测前GC情况:

image-20211023134101136

压测后GC情况:

img

可以看出,在压测后,出现了大量的 Full GC,这正是因为堆区内存过小导致的,严重拖慢了系统效率。并且从 GC 日志文件中也可以看到大量的 Full GC:

1
2
3
2021-10-23T09:58:50.445+0800: 129.233: [Full GC (Ergonomics) [PSYoungGen: 5632K->0K(6144K)] [ParOldGen: 12061K->12061K(13824K)] 17693K->12061K(19968K),[Metaspace: 20418K->20418K(1069056K)], 0.0134114 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
2021-10-23T09:58:50.547+0800: 129.335: [Full GC (Ergonomics) [PSYoungGen: 5632K->0K(6144K)] [ParOldGen: 12061K->12062K(13824K)] 17693K->12062K(19968K), [Metaspace: 20418K->20418K(1069056K)], 0.0137774 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
2021-10-23T09:58:50.648+0800: 129.436: [Full GC (Ergonomics) [PSYoungGen: 5632K->0K(6144K)] [ParOldGen: 12062K->12062K(13824K)] 17694K->12062K(19968K), [Metaspace: 20418K->20418K(1069056K)], 0.0149746 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]

优化配置:堆内存增大

现在增加堆内存空间大小:

1
2
3
4
5
6
7
8
export CATALINA_OPTS="$CATALINA_OPTS -Xms120m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:SurvivorRatio=8"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx120m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"

再进行 50 * 1000 次压测,观察吞吐量与GC情况与之前的区别。

吞吐量:

image-20211023100620829

压测前后GC情况:

img

这次可以看到,压测前后 Full GC 数量没有增加,只进行了少量了 Young GC,同时吞吐量也提升了,说明增大堆内存空间的确能够有效提高系统吞吐量。

调优案例二:逃逸分析

逃逸分析简介

如何将堆上的对象分配到栈,需要使用逃逸分析手段。逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。

JIT 编译器在开启逃逸分析后即可实现以下功能:

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指消除没必要的synchronized

注意:

  • 逃逸分析只有在 JIT 及时编译阶段才会对代码进行优化,在字节码文件里还是原样代码,并不会在前期编译就做优化。
  • Hotspot 并没有真正实现将对象存储在栈上,而是使用标量替换的形式将对象拆解成标量后存储在栈上,而不是直接将引用类型变量存储在栈上。即 Hotspot 中的栈上分配是通过标量替换实现的

逃逸分析示例

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
public class EscapeAnalysis {

public EscapeAnalysis obj;

/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果当前的obj引用声明为static的,会发生逃逸吗?会!

/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance();
//getInstance().xxx()同样会发生逃逸
}
/*
* 也发生了逃逸
* */
public void operate(EscapeAnalysis e){
// e
}
}

优化一:栈上分配

Hotspot 中的栈上分配是通过标量替换实现的,具体分析见后文。

首先测试开启栈上分配与不开启的区别,开启栈上分配在开启逃逸分析后即可开启。

开启逃逸分析

默认情况下即开启了逃逸分析,无需再手动指明 -XX:+DoEscapeAnalysis

只要开启了逃逸分析,就会判断方法中的变量是否发生了逃逸。如果没有发生逃逸,则会使用栈上分配,提高程序性能。

案例代码:

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
/**
* 栈上分配测试
* -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
*
* 只要开启了逃逸分析,就会判断方法中的变量是否发生了逃逸。如果没有发生逃逸,则会使用栈上分配
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();

for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}

private static void alloc() {
User user = new User(); // 是否发生逃逸? 没有!
}

static class User {

}
}

JVM 参数:

1
-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

开启了逃逸分析后,因为启动了栈上分配技术,所以运行速度极快,程序循环部分只耗时了 4 ms。并且通过 jvisualvm 观察内存情况,发现内存中并没有大量的 User 对象:

image-20211023102515646

关闭逃逸分析

通过参数 -XX:-DoEscapeAnalysis 可以关闭逃逸分析。

JVM 参数:

1
-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

关闭逃逸分析后,同样的程序耗时了 85 ms。并且通过 jvisualvm 观察内存情况,发现内存中有大量的 User 对象:

image-20211023102838831

优化二:标量替换

Hotspot 中的栈上分配是通过标量替换实现的,标量替换是 Hotspot 虚拟机逃逸分析的基础。

案例代码:

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
/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:-EliminateAllocations
*
* 结论:Java中的逃逸分析,其实优化的点就在于对栈上分配的对象进行标量替换。
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}

public static void alloc() {
User u = new User(); // 未发生逃逸
u.id = 5;
u.name = "www.xxx.com";
}

public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");

}
}

开启标量替换

标量替换默认情况下就是开启的,可以手动开启/关闭。并且标量替换若想开启,必须先开启逃逸分析:-XX:+DoEscapeAnalysis

1
-XX:+DoEscapeAnalysis -XX:+EliminateAllocations

首先开启标量替换,JVM参数:

1
-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:-EliminateAllocations

此时程序耗时大概 4 ms。

关闭标量替换

首先开启标量替换,JVM参数:

1
-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+EliminateAllocations

此时程序耗时大概 48 ms。

注意:此时即可看出一些端倪。本程序在不开启标量替换的情况下,JVM参数设置和栈上分配案例一样,同样都开启了-XX:+DoEscapeAnalysis,理应运行时间也只有几毫秒,但是本例却耗费了几十毫秒的时间,而不是像开启栈上分配案例一样只有 4ms。这说明了栈上分配的就是通过标量替换才实现的,不开启标量替换,栈上分配就无从谈起

对比

最后对比两种情况下打印的堆空间情况。

开启时:

image-20211023143332988

关闭时:

image-20211023143422165

可以看到开启标量替换后,伊甸园区的大小明显减小,说明的确将大量的对象拆解成了标量,存储在了栈上。

优化三:同步消除(省略)

如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。 线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SynchronizedTest {
public void f() {
/*
* 代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,
* 并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。
*
* 问题:字节码文件中会去掉hollis吗?:答:不会,只有在编译阶段才会去掉
* */
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}

/*
* 优化后;
* Object hollis = new Object();
* System.out.println(hollis);
* */
}
}

逃逸分析小结

逃逸分析并不成熟。关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。其根本原因就是无法保证非逃逸分析的性能消耗一定能高于他的消耗。

虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的,那这个逃逸分析的过程就白白浪费掉了

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

调优案例三:合理配置堆内存

在案例一中我们讲到了增加内存可以提高系统的性能而且效果显著,那么随之带来的一个问题就是,我们增加多少内存比较合适?

  • 如果内存过大,那么产生Full GC时,GC时间会相对比较长(因为遍历堆空间耗时增加了)
  • 如果内存较小,那么就会频繁触发GC

在这种情况下,我们该如何合理的适配堆内存大小呢?

官方推荐配置

我们可以根据Java虚拟机规范里 Java Performance 里面的推荐公式来进行设置:

image-20211023164346392

翻译:

  • Java整个堆大小:-Xmx-Xms 设置为老年代存活对象3 - 4 倍,即Full GC之后的老年代内存占用的3-4倍。并且二者设置相同,避免动态调整堆大小造成性能损耗
  • 方法区(永久代或元空间)大小:设置为老年代存活对象的 1.2 - 1.5
  • 年轻代大小:-Xmn 设置为老年代存活对象的 1 - 1.5
  • 老年代大小:设置为老年代存活对象的 2 - 3 倍(大小为-Xmx大小减去-Xmn大小)

但是,上面的说法也不是绝对的,也就是说这给的是一个参考值,根据多种调优之后得出的一个结论,可以根据这个值来设置一下我们的初始化内存,在保证程序正常运行的情况下,我们还要去查看GC的回收率,GC停顿耗时,内存里的实际数据来判断,Full GC是基本上不能有的,如果有就要做内存Dump分析,然后再去做一个合理的内存分配。

我们还要注意到一点就是,上面说的老年代存活对象怎么去判定。

如何计算老年代存活对象

要么在运行时在命令行打印的日志里观察,要么直接dump一次,强制做一次GC,然后分析GC日志。

方式一:查看日志(推荐)

JVM参数中添加GC日志-XX:+PrintGCDetails),GC日志中会记录每次Full GC之后各代的内存大小,观察老年代GC之后的空间大小。可观察一段时间内(比如2天)Full GC之后的内存情况,根据多次的Full GC之后的老年代的空间大小数据来预估Full GC之后老年代的存活对象大小(可根据多次Full GC之后的内存大小取平均值)。

方式二:强制触发FullGC

会影响线上服务,慎用!

方式一比较可行,但需要更改JVM参数,并分析日志。同时,在使用CMS回收器的时候,有可能不能触发Full GC(因为运行中CMS的一次Full GC很可能会使用老年代串行Serial Old GC,它的速度是极慢的),或者程序上线后一直没有GC过,所以没有打印过日志,即日志中并没有记录Full GC的日志,这在分析的时候就比较难处理。 所以,有时候需要强制触发一次Full GC,来观察FullGC之后的老年代存活对象大小。

注:强制触发Full GC,会造成线上服务停顿(STW),要谨慎!建议的操作方式为:在强制Full GC前先把服务节点摘除,Full GC之后再将服务挂回可用节点,对外提供服务,在不同时间段触发Full GC,根据多次Full GC之后的老年代内存情况来预估Full GC之后的老年代存活对象大小。

线上服务进行压测比强制Full GC影响更大,会导致极大的STW,谨慎使用。

如何强制触发Full GC?

  • jmap -dump:live,format=b,file=heap.bin <pid>:将当前的存活对象dump到文件,此时会触发FullGC。会顺带触发一次Full GC
  • jmap -histo:live <pid>:打印每个class的实例数目、内存占用、类全名信息。.live子参数加上后,只统计活的对象数量。此时会顺带触发一次Full GC
  • 在性能测试环境,可以通过Java监控工具来触发Full GC,比如使用VisualVM和JConsole,VisualVM集成了JConsole,VisualVM或者JConsole上面有一个触发GC的按钮。

生成 dump 文件时都会顺便触发一次 Full GC

案例演示

将内存初始化为1024M。JVM配置参数:

1
2
3
4
-XX:MetaspaceSize=64m -Xss512K -Xms1024M  -Xmx1024M 
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdump3.hprof
-XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-Xloggc:log/gc-oom3.log

编写代码,每次请求都从数据库中查询指令,模拟真实业务:

1
2
3
4
5
@RequestMapping("/getData")
public List<User> getProduct(){
List<User> userList = userSevice.getUserList();
return userList;
}

数据分析

初始情况

项目启动,通过jmeter访问10000次(主要是看项目是否可以正常运行)之后,查看GC状态:

1
jstat -gc pid

image-20211025094508183

  • YGC 平均耗时: 0.12s * 1000 / 7 = 17.14ms
  • FGC 未产生

看起来似乎不错,YGC触发的频率不高,FGC也没有产生,但这样的内存设置是否还可以继续优化呢?是不是有一些空间是浪费的呢。

查看老年代存活对象大小

为了快速看数据,我们使用了方式2,通过命令 jmap -histo:live pid 产生几次Full GC,Full GC之后,使用的 jmap -heap 来看的当前的堆内存情况。

1
jmap -heap pid

观察老年代存活对象大小:

image-20211025095004147

可以看到存活对象占用内存空间大概13.36M,老年代的内存占用为683M左右,这说明大量的老年代空间都是冗余的,这无疑会降低每次GC时的耗时。 按照整个堆大小是老年代 Full GC 之后剩余内存大小的3 - 4倍计算的话,设置堆内存情况如下:

1
-Xmx = 14 * 3 = 42M  至  14 * 4 = 56M 之间

优化配置:修改堆内存大小

我们修改堆内存状态如下:

1
2
3
4
-XX:+PrintGCDetails -XX:MetaspaceSize=64m -Xss512K 
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdump.hprof
-XX:SurvivorRatio=8 -XX:+PrintGCDateStamps
-Xms60M -Xmx60M -Xloggc:log/gc-oom.log

修改完之后,再次进行压测后查看一下GC状态:

image-20211025095401459

  • YGC平均耗时: 0.195s * 1000 / 68 = 2.87ms
  • FGC未产生

对比优化前后的GC日志,可以看出:

  • GC频率比优化前要多了一些,这是因为堆内存空间变小了
  • 但是YGC的平均耗时却明显减少
  • 同时依然未产生Full GC

所以我们内存设置为60M也是比较合理的,相对之前节省了很大一块内存空间,并且平均YGC也较小。

依然手动触发Full GC,查看堆内存结构:

image-20211025095747754

可以看到还有很多冗余空间,不会导致Full GC

结论

在内存相对紧张的情况下,可以按照上述的方式来进行内存的调优, 找到一个在GC频率和GC耗时上都可接受的一个内存设置,可以用较小的内存满足当前的服务需要。

但当内存相对宽裕的时候,可以相对给服务多增加一点内存,可以减少GC的频率,GC的耗时相应会增加一些。 一般要求低延时的可以考虑多设置一点内存, 对延时要求不高的,可以按照上述方式设置较小内存。

如果在垃圾回收日志中观察到OutOfMemoryError,尝试把Java堆的大小扩大到物理内存的80%~90%。尤其需要注意的是堆空间导致的OutOfMemoryError以及一定要增加空间。

  • 比如说,增加-Xms-Xmx的值来解决Old代的OutOfMemoryError
  • 增加-XX:MetaspaceSize-XX:MaxMetaspaceSize来解决Metaspace引起的OutOfMemoryError(jdk8之后)

记住一点,Java堆能够使用的容量受限于硬件以及是否使用64位的JVM。在扩大了Java堆的大小之后,再检查垃圾回收日志,直到没有OutOfMemoryError为止。如果应用运行在稳定状态下没有OutOfMemoryError就可以进入下一步了:计算活动对象的大小。

补充问题:你会估算 GC 频率吗?

正常情况我们应该根据我们的系统来进行一个内存的估算,这个我们可以在测试环境进行测试,最开始可以将内存设置的大一些,比如4G这样,当然这也可以根据业务系统估算来的。

比如从数据库获取一条数据占用128个字节,需要获取1000条数据,那么一次读取到内存的大小就是(128 B/1024 Kb/1024M)* 1000 = 0.122M ,那么我们程序可能需要并发读取,比如每秒读取100次,那么内存占用就是0.122 * 100 = 12.2M ,如果堆内存设置1个G,那么年轻代大小大约就是333M,那么333M * 80% / 12.2M =21.84s ,也就是说我们的程序几乎每分钟进行两到三次youngGC。这样可以让我们对系统有一个大致的估算。

  • 0.122M * 100 = 12.2M /秒 —Eden区
  • 1024M * 1/3 * 80% = 273M
  • 273 / 12.2M = 22.38s —> YGC 每分钟2-3次YGC

特殊问题:新生代与老年代的比例

参数设置

JVM 参数设置为:

1
2
-XX:+PrintGCDetails   -XX:+PrintGCDateStamps  
-Xms300M -Xmx300M -Xloggc:log/gc.log

新生代 ( Young ) 与老年代 ( Old ) 的比例为 1:2。所以,内存分配应该是新生代100M,老年代 200M。我们可以先用命令查看一下堆内存分配是怎么样的:

1
2
3
4
5
# 查看进程ID
jps -l

# 查看对应的进程ID的堆内存分配
jmap -heap 3725

结果看到:虽然默认配置的SurvivorRatio= 8,但是内存分配却不是8:1:1,这是为什么呢?

AdaptiveSizePolicy 参数

这是因为JDK 1.8 默认使用 Parallel 垃圾回收器,该垃圾回收器默认启动了 AdaptiveSizePolicy(可以选择关闭该参数),会根据GC的情况自动计算计算 Eden、From 和 To 区的大小;所以这是由于JDK 1.8的自适应大小策略导致的,除此之外,我们下面观察GC日志发现有很多类似这样的FULL GC(Ergonomics),也是一样的原因。

我们可以在JVM参数中配置开启和关闭该配置:

1
2
3
4
5
# 开启:
-XX:+UseAdaptiveSizePolicy

# 关闭
-XX:-UseAdaptiveSizePolicy

注意事项:

  • 在 JDK 1.8 中,如果使用 CMS,无论 UseAdaptiveSizePolicy 如何设置,都会将 UseAdaptiveSizePolicy 设置为 false;不过不同版本的JDK存在差异;
  • UseAdaptiveSizePolicy不要和SurvivorRatio参数显示设置搭配使用,一起使用会导致参数失效;
  • 由于UseAdaptiveSizePolicy会动态调整 Eden、Survivor 的大小,有些情况存在Survivor被自动调为很小,比如十几MB甚至几MB的可能,这个时候YGC回收掉Eden区后,还存活的对象进入Survivor 装不下,就会直接晋升到老年代,导致老年代占用空间逐渐增加,从而触发FULL GC,如果一次FULL GC的耗时很长(比如到达几百毫秒),那么在要求高响应的系统就是不可取的。

对于面向外部的大流量低延迟系统,不建议启用此参数,因为动态调整很可能降低系统的延迟性。

如果不想动态调整内存大小,以下是解决方案:

  • 保持使用 UseParallelGC,显式设置 -XX:SurvivorRatio=8
  • 使用 CMS 垃圾回收器。CMS 默认关闭 AdaptiveSizePolicy。配置参数 -XX:+UseConcMarkSweepGC

调优案例四:CPU 占用很高排查方案

案例模拟

死锁案例

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class JstackDeadLockDemo {
/**
* 必须有两个可以被加锁的对象才能产生死锁,只有一个不会产生死锁问题
*/
private final Object obj1 = new Object();
private final Object obj2 = new Object();

public static void main(String[] args) {
new JstackDeadLockDemo().testDeadlock();
}

private void testDeadlock() {
Thread t1 = new Thread(() -> calLock_Obj1_First());
Thread t2 = new Thread(() -> calLock_Obj2_First());
t1.start();
t2.start();
}

/**
* 先synchronized obj1,再synchronized obj2
*/
private void calLock_Obj1_First() {
synchronized (obj1) {
sleep();
System.out.println("已经拿到obj1的对象锁,接下来等待obj2的对象锁");
synchronized (obj2) {
sleep();
}
}
}

/**
* 先synchronized obj2,再synchronized obj1
*/
private void calLock_Obj2_First() {
synchronized (obj2) {
sleep();
System.out.println("已经拿到obj2的对象锁,接下来等待obj1的对象锁");
synchronized (obj1) {
sleep();
}
}
}

/**
* 为了便于让两个线程分别锁住其中一个对象,
* 一个线程锁住obj1,然后一直等待obj2,
* 另一个线程锁住obj2,然后一直等待obj1,
* 然后就是一直等待,死锁产生
*/
private void sleep() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

问题呈现

将上述代码运行在Linux服务器上:

image-20211025143122524

可以看到,程序依然处于运行状态。现在我们知道是线程死锁造成的问题。

问题分析

那么如果是生产环境的话,是怎么样才能发现目前程序有问题呢?我们可以推导一下,如果线程死锁,那么线程一直在占用CPU,这样就会导致CPU一直处于一个比较高的占用率。所示我们解决问题的思路应该是:

  • 首先查看java进程ID
  • 根据进程 ID 检查当前使用异常线程的pid
  • 把线程pid变为16进制如 31695 -> 7bcf 然后得到 0x7bcf
  • jstack pid | grep -A20 0x7bcf 得到相关进程的代码 (鉴于我们当前代码量比较小,线程也比较少,所以我们就把所有的信息全部导出来)

-A20:after 20,显示目标行后的20行内容

接下来是我们的实现上面逻辑的步骤,如下所示:

1
2
# 查看所有java进程 ID
jps -l

image-20211025143250556

1
2
# 根据进程 ID 检查当前使用异常线程的pid
top -Hp 1456

image-20211025143305762

从上图可以看出来,当前占用cpu比较高的线程 ID 是1465。把线程 PID 转换为16进制为:0x5b9

最后我们把线程信息打印出来:

image-20211025143339575

所有的准备工作已经完成,我们接下来分析日志中的信息,来定位问题出在哪里。

打开jstack.log文件 查找一下刚刚我们转换完的16进制ID是否存在:

image-20211025143359293

jstack命令生成的thread dump信息包含了JVM中所有存活的线程,里面确实是存在我们定位到的线程 ID ,在thread dump中每个线程都有一个nid,在nid=0x5b9的线程调用栈中,我们发现两个线程在互相等待对方释放资源

image-20211025143413295

到此就可以检查对应的代码是否有问题,也就定位到我们的死锁问题。

解决方案

  • 调整锁的顺序,保持一致
  • 或者采用定时锁,一段时间后,如果还不能获取到锁就释放自身持有的所有锁。

总结

  • ps aux | grep java 查看到当前java进程使用cpu、内存、磁盘的情况获取使用量异常的进程
  • top -Hp pid 检查当前使用异常线程的pid
  • 把线程pid变为16进制如 31695 -> 7bcf 然后得到0x7bcf
  • jstack pid | grep -A20 0x7bcf 得到相关进程的代码

调优案例五:G1 并发标记线程数对性能的影响

配置信息

硬件配置:8核 Linux

JVM参数设置:

1
2
3
4
5
6
7
8
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseG1GC"
export CATALINA_OPTS="$CATALINA_OPTS -Xms30m"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx30m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"
export CATALINA_OPTS="$CATALINA_OPTS -XX:ConcGCThreads=1"

说明:最后一个参数可以在使用G1 GC测试初始并发GCThreads之后再加上。并且初始化内存和最大内存调整小一些,目的发生 FullGC,关注GC时间。

关注点是:GC次数,GC时间,以及Jmeter的平均响应时间。

初始状态

1、启动Tomcat,查看进程默认的并发线程数:

1
2
jinfo -flag ConcGCThreads pid
-XX:ConcGCThreads=1

没有配置的情况下:并发标记线程数是1。

2、查看线程状态:

image-20211025144209223

  • YGC:Young GC 次数是 1259 次
  • FGC:Full GC 次数是 6 次
  • GCT:GC 总时间是 5.556 s

3、Jmeter 压测之后的GC状态:

image-20211025144315086

  • YGC:Young GC 次数是 1600 次
  • FGC:Full GC 次数是 18 次
  • GCT:GC 总时间是 7.919 s

由此我们可以计算出来压测过程中,发生的GC次数和GC时间差。


整个压测过程中 GC 状态:

  • YGC:Young GC 次数是 1600 - 1259 = 341 次
  • FGC:Full GC 次数是 18 - 6 = 12 次
  • GCT:GC 总时间是 7.919 - 5.556 = 2.363 s

Jmeter压测结果,主要关注响应时间:

  • 95% 的请求响应时间为:16ms
  • 99% 的请求响应时间为:28ms

image-20211025144532203


优化之后

增加并发线程配置(建议设置为CPU核数的1/4,太大也没有意义,反而抢占了并发用户线程):

1
export CATALINA_OPTS="$CATALINA_OPTS -XX:ConcGCThreads=2"

1、Tomcat启动之后的初始化GC状态:

image-20211025144843498

  • YGC:Young GC 次数是 1134 次
  • FGC:Full GC 次数是 5 次
  • GCT:GC 总时间是 5.234 s

2、Jmeter 压测之后的GC状态:

image-20211025144930791

  • YGC:Young GC 次数是 1347 次
  • FGC:Full GC 次数是 16 次
  • GCT:GC 总时间是 7.149 s

由此我们可以计算出来压测过程中,发生的GC次数和GC时间差


压测过程GC状态:

  • YGC:Young GC 次数是 1347 - 1134 = 213 次
  • FGC:Full GC 次数是 16 - 5 = 13 次
  • GCT:GC 总时间是 7.149 - 5.234 = 1.915 s

对比优化后的GCT时间,发现比单线程时时间更短。这说明增大并发标记线程数,使得GC时间降低了。

Jmeter压测结果,主要关注响应时间:

  • 95%的请求响应时间为:15ms
  • 99%的请求响应时间为:22ms

请求响应时间确实比优化前单线程更低了。

image-20211025145515800


总结

配置完线程数之后,我们的请求的平均响应时间和GC时间都有一个明显的减少了,仅从效果上来看,我们这次的优化是有一定效果的。在工作中对于线上项目进行优化的时候,可以考虑到这方面的优化。

结论:增大并发标记线程数可以减小响应延迟时间。并且并发标记线程数设置太大也没更明显变化,反而因为抢占用户线程而导致。经验:并发标记线程数设置为CPU核数的 1/4 即可

调优案例六:调整垃圾回收器提高服务的吞吐量

初始配置

初始系统配置是单核CPU,我们看到日志,显示DefNew,说明我们用的是串行收集器:SerialGC

优化配置一

那么就考虑切换一下并行收集器是否可以提高性能,增加配置如下:

1
2
3
4
5
6
7
export CATALINA_OPTS="$CATALINA_OPTS -Xms60m"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx60m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc6.log"

进行压测后查看GC状态:

image-20211025151418354

发生3次Full GC,可以接受。

查看吞吐量:

image-20211025151446184

发现吞吐量并没有明显变化,我们究其原因,本身UseParallelGC是并行收集器,但是我们的服务器是单核,所以变化不大。

优化配置二

接下来我们把服务器改为8核。

image-20211025151608283

8核状态下的性能表现如下,吞吐量大幅提升,甚至翻了一倍,这说明我们在多核机器上面采用并行收集器对于系统的吞吐量有一个显著的效果

优化配置三

接下来我们改为G1收集器看看效果

1
2
3
4
5
6
7
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseG1GC"
export CATALINA_OPTS="$CATALINA_OPTS -Xms60m"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx60m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc6.log"

查看GC状态:

image-20211025151657880

没有产生Full GC,效果较之前有提升。

查看压测效果,吞吐量也是比串行收集器效果更佳,而且没有了Full GC。此次优化较为成功。

image-20211025151701558

总结

在多核CPU上,使用并行垃圾收集器的效果要好于串行垃圾收集器(体现在吞吐量上),并且G1更能提高服务的吞吐量。

调优案例七:日均百万级订单交易系统如何设置 JVM 参数

首先针对吞吐量进行分析:

正常数据量情况

正常数据量情况下的分析步骤:

  • 首先计算一秒的订单量,从而估算出一秒有多少kb数据
  • 将该数据量扩大20倍,以加上其他业务信息
  • 计算出正常情况下每秒在伊甸园区创建2M对象,从而算出11分钟会让伊甸园区占满。

十几分钟处于可以接受的范围。

我们可以继续优化:提高新生代所占比例。因为大量的订单都是临时数据,不需要将他们放到老年代,否则会增加Full GC。

业务暴增情况

业务量暴增时数据量可能增加几十倍,此时的处理方案:水平扩容,多增加一些服务器,分摊压力。

然而如果不扩容机器,则会有两种因素导致系统性能降低:

  • 订单数量暴增,新生代每秒新增对象增加到几十兆
  • 同时,由于系统压力,一个订单几秒到几十秒才可生成,所以新生代的订单对象可能存活几秒到几十秒(生命周期变长)

这两个因素共同导致每二三十秒就会发生一次Minor GC,导致过多对象进入老年代,直到Full GC,这无疑会增加系统的延迟响应时间,降低性能。

优化思路:增大年轻代的内存空间,尽量让大量对象都在新生代,减少Full GC

上述分析过程的几个影响因素:

  • 机器的配置、水平扩容的配置
  • 每秒的订单数量
  • 年轻代的内存大小

总结

针对日均百万级订单交易系统,可以进行的一些优化设置:

  • 水平扩容,增加服务器数量,增加硬件性能
  • 提高JVM内存空间大小,尤其是新生代的大小,让大量临时订单对象停留在新生代,不让其进入老年代
  • 使用 G1 垃圾回收器,增加服务吞吐量
  • 增大 G1 GC 中并发GC线程数量,设置为系统CPU核数的1/4

扩展:问如何进行服务器配置只是第一个层面的问题!第二个层面问题:如果要求响应时间控制在100ms如何实现

思路:使用G1垃圾回收器,通过参数 -XX:MaxGCPauseMillis 设置暂停时间(设置该参数无法保证高吞吐量)。同时在上线前进行压测,根据延迟时间对JVM参数进行调整,并且关注GC频率与吞吐量,通过反复压测调整参数的方式,控制响应时间。


面试题

1、12306遭遇春节大规模抢票如何支撑?

普通电商订单–> 下单 --> 订单系统(IO)减库存 —> 等待用户付款

12306一种可能的模型:下单 --> 减库存和订单(redis、kafka)同时异步进行 --> 等付款
但减库存最后还会把压力压到一台服务器上。如何?

分布式本地库存 + 单独服务器做库存均衡

2、有一个50万PV的资料类网站(从磁盘提取文档到内存)原服务器是32位的,1.5G的堆,用户反馈网站比较缓慢。因此公司决定升级,新的服务器为64位,16G的堆内存,结果用户反馈卡顿十分严重,反而比以前效率更低了!

  1. 为什么原网站慢?频繁的GC,STW时间比较长,响应时间慢!
  2. 为什么会更卡顿?内存空间越大,FGC时间更长,延迟时间更长
  3. 怎么解决?

垃圾回收器:parallel GC ; ParNew + CMS ; G1
配置GC参数:-XX:MaxGCPauseMillis 、 -XX:ConcGCThreads
根据log日志、dump文件分析,优化内存空间的比例
jstat jinfo jstack jmap

3、系统CPU经常100%,如何调优?(面试高频)

CPU100%的话,一定是有线程占用系统资源。具体步骤前面已经讲过。

注意: 工作中有时候是工作线程100%占用了CPU,还有可能是垃圾回收线程占用了100%

4、系统内存飙高,如何查找问题?(面试高频)

  • 一方面:jmap -heap 、jstat 、… ; gc日志情况
  • 另一方面:dump文件分析

5、如何监控 JVM

命令行工具
图形化界面工具