前言

关于java的性能问题,涵盖的面相当广泛,从本地程序到web、分布式、大数据都有很多方面值得注意和推敲,本次仅结合java虚拟机相关的基础知识探讨下平时开发和部署过程中遇到的问题和需要注意的细节点。

对于Java程序员来说, 在虚拟机的自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,而且不容易出现内存泄漏和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为Java程序员把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题, 如果不了解虚拟机是怎样使用内存的, 那排查错误将会成为一项异常艰难的工作。

JVM虚拟机基础知识

运行时的数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。 这些区域都有各自的用途, 以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在, 有些区域则是依赖用户线程的启动和结束而建立和销毁。

方法区:为了便于理解可以简单地认为它就是存放静态数据的位置,也就是所谓的永久代,但是实际上
这个区域是虚拟机规范最为宽松的位置,可以有各种实现,甚至不实现而使用直接内存,进入该区域的
数据也并不是真的永久存在,gc在该区域发生的很少,常量池回收和类型卸载依然会存在。

虚拟机栈(stack):它所描述的内存模型:每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
简单来讲,对应到web应用就是处理用户请求的线程区域,他是线程私有的。

本地方法栈:与虚拟机栈原理一样,区别在于它为java本地库的执行服务。

堆(heap):它和虚拟机栈一样,使我们在开发中经常会遇到的字眼,也是内存异常最多的区域之一。它简单的理解就是存放对象实例,它是被所有线程共享的,垃圾回收的主要区域也在这里。

程序计数器:它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。简单理解一句话,控制代码的运行逻辑。这也是jvm运行区域里面唯一不存在内存泄漏情况的区域,平时开发过程中基本不需要关注。

结合上面的简单介绍,我们现在大致应该清楚jvm里面三个最为重要的内存区域就是java栈,java堆和方法区,后面所有的分享将会围绕这三个重点区域展开。

对象访问

接下来看下我们最为常见的新建对象及对象的访问在虚拟机都发生了什么。

对象初始化时发生了什么

1
Object obj = new Object();

如果这句代码出现在方法体中,“Object obj”这部分的语义会反映到java栈的变量表中,作为一个引用(Reference)类型数据出现,而“new Object()”这部分的语义将会反映到java堆中,形成一块存储Object类型所有实例数据值的结构化内存,此外java堆必须包含能查到此对象类型数据的地址信息。

访问对象是如何进行的

由于引用(reference)类型在jvm里面只是一种规范,至于这个引用怎么实现,不同的虚拟机会有不同的实现方式,比较主流的有两种:句柄和直接指针。

  • 如果使用句柄的方式访问,java堆中将会划分中一块内存在作为句柄池,引用中存储的就是对象的句柄地址,二局并重包含了该对象所有数据的地址信息。
  • 如果使用直接指针进行访问,java堆对象的的布局就得考虑如何把需要访问的类型数据放置好,引用中存储的就是对象的句柄存储的就是对象的地址。

综上,这两种方式各有优劣,句柄访问最大的好处就是变量表存储的是稳定的句柄地址,对象被移动时只会改变句柄中实例数据指针,而栈里面的引用本身是不需要被修改的。

而指针访问最大的好处就是快,节省了一次指针定位的开销,对象的访问在java中是非常频繁的,这在性能提升方面是非常具有优势的。

常见的内存异常(OutOfMemoryError)

  • OutOfMemoryError: java heap space…
  • StackOverflowError
  • OutOfMemoryError: new native thread
  • OutOfMemoryError: PermGen space

java堆溢出(OutOfMemoryError: java heap space…)
实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 堆溢出
* VM Args:- Xms20m -Xmx20m -XX:+ HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {

private static void exec() {
List list = new ArrayList();
while (true) {
list.add(new Object());
}
}

public static void main(String[] args) {
exec();
}
}

java栈及本地方法栈溢出(StackOverflowError),如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出该异常,单个线程下,无论是栈帧太大还是栈容量太小(Xss),当内存无法分配的时候,虚拟机都是抛出StackOverflowError的异常。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
  /**
* 栈帧溢出
* VM Args: -Xss160k
*/
private void exec() {
exec();
}

public static void main(String[] args) {
StackLeak sl = new StackLeak();
sl.exec();
}

创建多线程导致内存溢出(OutOfMemoryError: new native thread),一般情况下,给每个线程的栈分配的内存越大,当线程数增多的时候越容易产生内存溢出的异常
示例代码:

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
/**
* 线程内存溢出
* VM Args: -Xss160k
*/
public class StackOOM {

private void noStop() {
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public void exec() {
while (true) {
Thread thread = new Thread(() -> noStop());
thread.start();
}
}

public static void main(String[] args) {
StackOOM so = new StackOOM();
so.exec();
}
}

运行时常量池溢出(OutOfMemoryError: PermGen space),如果要向运行时的常量池中添加内容,最简单的做法就是使用String.intern()这个Native方法。该方法的作用是:如果翅中已经包含一个等于此对象的字符串,则返回这个对象,否则,将该对象添加到常量池中。
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String str = "sss";

/**
* 常量池内存溢出
* VM Args:- XX: PermSize= 10M -XX: MaxPermSize= 10M (java1.7及以下版本)
* VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M (java1.8及以上版本)
*/
private void exec() {
List<String> list = new ArrayList<>();
while (true) {
str = str + str;
list.add(str.intern());
}
}

public static void main(String[] args) {
ConstantPoolOOM cp = new ConstantPoolOOM();
cp.exec();
}

内存分配与垃圾回收策略

垃圾回收的算法其实有很多种,譬如常见的引用计数算法和跟搜索算法,感兴趣的朋友可以自行了解,这里不做展开。这里主要强调作为开发者必须要了解的垃圾回收机制及相应的规则。

内存回收区域

内存回收区域大致分为新生代(包含eden和survivor)区域和老年代区域,对应的回收分别为minor GC和major GC(full GC),分别对应的含义:

  • Eden,新生对象内存分配的区域
  • survivor,可以理解为缓冲区,避免内存大量涌入老年代,导致full GC
  • old, 长时间存活的对象及大文件连续内存存放的区域

先来看一段代码,加深理解:

1
2
3
4
5
6
7
8
9
10
private static final int _1MB = 1024 * 1024;

/** * VM 参数:- verbose: gc -Xms20M -Xmx20M -Xmn10M -XX: SurvivorRatio= 8 */
public static void exec() {
byte[] a1, a2, a3, a4;
a1 = new byte[ 2 * _1MB];
a2 = new byte[ 2 * _1MB];
a3 = new byte[ 2 * _1MB];
a4 = new byte[ 5 * _1MB];
}

对象优先在Eden(新生代)分配

大多数情况下,对象在新生代Eden区中分配。 当Eden区没有足够的空间进行分配时, 虚拟机 将发起一次Minor GC,也就是回收新生代的内存。

虚拟机提供了-XX: +PrintGCDetails收集器日志参数,可以打印回收日志。

大对象直接进入老年代

所谓的大对象就是指需要大量连续内存空间的java对象,大量的生存周期很短的大对象进入内存,会导致内存空间足够的情况下,频繁触发垃圾回收。

虚拟机提供了一个-XX:PretenureSIzeThreshold参数可以让大于这个设置值的对象直接在老年代分配,这样是为了避免在Eden和Survivor区域出现大量且频繁的内存拷贝

长期存活的对象进入老年代

这个顾名思义,比较好理解,内存分代收集进行管理,目的也在于此。参数MaxTenuringThreshold可以用来设置新生代经历多少次Minor GC才能够被转移到老年代

更加进阶的概念包括动态对象年龄判定,空间分配担保这里就留给大家自行研究理解

场景探讨

  1. 系统导入大文件时频繁出现系统卡死无响应甚至崩溃的原因分析。
  2. 服务器不定时发生内存溢出,但是java堆日志和虚拟机监控均发现不了任何问题。
  3. 服务器虚拟机进程自动关闭,且报出SocketException: Connection reset的异常。