JVM

Java 虚拟机(JVM)

JVM

Java 源码,经过编译器编译后生成 .class 字节码文件:

编译

Java 源码编译由以下三个过程组成:

  • 分析和输入到符号表
  • 注解处理
  • 语义分析和生成class文件

源码编译

JVM 将字节码文件翻译成特定平台下的机器码然后运行:

字节码翻译

注:编译生成的是字节码,字节码不能直接运行,必须通过 JVM 翻译成机器码才能运行。不同平台下编译生成的字节码是一样的,但是由 JVM 翻译成的机器码却不一样。

注:跨平台的是 Java 程序,不是 JVM。JVM 是用 C/C++ 开发的,不能跨平台,不同平台下需要安装不同版本的 JVM。

内存区域

根据《Java 虚拟机规范》,运行时数据区通常包括这几个部分:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。

JVM 运行时的内存划分

规范中虽然规定了程序在执行期间运行时数据区应该包括这几部分,但是至于具体如何实现并没有做出规定,不同的虚拟机厂商可以有不同的实现方式。

程序计数器(Program Counter Register)

程序计数器是一个比较小的内存区域,作用:指示当前线程所执行的字节码执行到了第几行。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。

在 JVM 中,多线程是通过线程轮流切换来获得CPU执行时间的,所以在任一具体时刻,一个CPU的内核只会执行一条线程中的指令。为了使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,程序计数器是每个线程所私有的。

  • 如果程序执行的是一个 Java 方法,则计数器记录的是正在执行的虚拟机字节码指令地址;
  • 如果正在执行的是一个本地(native,由 C 语言编写完成)方法,则计数器的值为 Undefined。

由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此程序计数器也是所有 JVM 内存区域中唯一一个没有定义 OutOfMemoryError 的区域。

虚拟机栈(JVM Stack)

虚拟机栈是 Java 方法执行的内存模型

当线程执行一个方法时,就会创建一个相应的栈帧(Statck Frame),并将建立的栈帧压栈,当方法执行完后,便会将栈帧出栈。

栈帧中存储了局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接、方法返回地址(Return Address)等。

栈帧

局部变量表中存储着方法的相关局部变量(包括在方法中声明的非静态变量以及函数形参)。基本类型中只有 long 和 double 类型会占用2个局部变量空间(Slot,对于32位机器,1 Slot = 32 bit),其它都是1个局部变量空间。

注:局部变量表在编译时就已确定,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

虚拟机栈中定义了两种异常:

  • 栈溢出(StatckOverFlowError):如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出;
  • 内存溢出(OutOfMemoryError):多数 JVM 允许动态扩展虚拟机栈的大小,如果线程一直申请栈,直到内存不足,则抛出。

每个线程对应着一个虚拟机栈,因此虚拟机栈也是线程私有的。

本地方法栈(Native Method Statck)

本地方法栈在作用,运行机制,异常类型等方面都与虚拟机栈相同。

唯一区别:虚拟机栈用于执行 Java 方法;本地方法栈用于执行本地方法(Native Method)。

在规范中并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。比如 Sun 的 JDK 默认的 HotSpot 虚拟机就直接把本地方法栈与虚拟机栈放在一起使用。

本地方法栈也是线程私有的。

堆区(Heap)

堆区是理解 GC 机制最重要的区域。在 JVM 所管理的内存中,堆区最大,是 GC 机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区的存在是为了存储对象实例,原则上讲,所有的对象以及数组都在堆区上分配内存(当然也有栈上直接分配的)。

根据 JVM 规范,堆内存需要在逻辑上是连续的(在物理上不需要),在实现时,可以是固定大小的,也可以是可扩展的,目前主流的虚拟机都是可扩展的。如果在执行垃圾回收之后,仍然没有足够的内存可分配,也不能进行扩展,将会抛出 OutOfMemoryError:Java heap space 异常。

堆是被所有线程共享的,在 JVM 中只有一个堆。

方法区(Method Area)

方法区在 JVM 中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。

在 JVM 规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为 HotSpot 虚拟机将分代收集的思想扩展到了方法区,并将方法区设计成了永久代,从而 JVM 的垃圾收集器可以像管理堆区一样管理这部分区域。但实际上,方法区并不是堆(Non-Heap),从而不需要专门为这部分设计垃圾回收机制。而自从 JDK7 之后,Hotspot 虚拟机便将运行时常量池从永久代移除了。

在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。

在 Class 文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到 JVM 后,对应的运行时常量池就被创建出来。

运行时常量池(Runtime Constant Pool):

  • 方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);
  • 运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量(比如 String 类的 intern() 方法,作用是 String 维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址)。

方法区在物理上不需要连续,可以选择固定大小或可扩展大小,并且方法区比堆还多了一个限制:可以选择是否执行垃圾收集。一般的,方法区上执行的垃圾收集是很少的,这也是方法区被称为永久代的原因之一(HotSpot),但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。

在方法区上进行垃圾收集,条件苛刻而且相当困难,效果也不令人满意,所以一般不做太多考虑,可以留作以后进一步深入研究时使用。

方法区定义了 OutOfMemoryError:PermGen space 异常,在内存不足时抛出。

直接内存(Direct Memory)

直接内存并不是 JVM 管理的内存,是 JVM 以外的机器内存(比如机器有4G内存,JVM 占用了1G,则其余的3G就是直接内存)。

JDK 中有一种基于通道(Channel)和缓冲区(Buffer)的内存分配方式,将由 C 语言实现的 native 函数库分配在直接内存中,用存储在 JVM 堆中的 DirectByteBuffer 来引用。由于直接内存受本机器内存的限制,所以可能出现 OutOfMemoryError。

对象的访问方式

引用访问涉及到 JVM 的三个内存区域:栈,堆,方法区。

举个栗子:

1
Object obj = new Object();
  • obj 表示一个本地引用,存储在 JVM 栈的本地变量表中;
  • new 出来的 Object 作为实例对象数据存储在堆中;
  • 堆中还记录了 Object 类的类型信息(接口、方法、field、对象类型等)的地址,这些地址所执行的数据存储在方法区中。

在 JVM 规范中,对于通过引用类型引用访问具体对象的方式并未做规定,目前主流的实现方式主要有两种:

通过句柄访问

通过句柄访问——《深入理解Java虚拟机:JVM高级特效与最佳实现》

通过句柄访问的实现方式中,JVM 堆中会专门有一块区域用来作为句柄池,存储相关句柄所执行的实例数据地址(包括在堆中地址和在方法区中的地址)。这种实现方法由于用句柄表示地址,因此十分稳定。

通过直接指针访问

通过直接指针访问——《深入理解Java虚拟机:JVM高级特效与最佳实现》

通过直接指针访问的方式中,引用中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在 HotSpot 虚拟机中用的就是这种方式。

内存溢出

常见的错误提示:

  • tomcat:java.lang.OutOfMemoryError:PermGen space
  • tomcat:java.lang.OutOfMemoryError:Java heap space
  • java:java.lang.OutOfMemoryError

导致 OutOfMemoryError 异常的常见原因:

  • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据
  • 集合类中有对对象的引用,使用完后未清空,使得 JVM 不能回收
  • 代码中存在死循环或循环产生过多重复的对象实体
  • 使用的第三方软件中的BUG
  • 启动参数内存值设定过小

java.lang.OutOfMemoryError

增加 JVM 内存大小:

  • 在执行某个 Class 文件时,设置 -Xmx256M 所允许占用的最大内存为256M。
  • 对 Tomcat 容器,可以在启动时设置内存限度。在 catalina.bat 中添加:
    set CATALINA_OPTS=-Xms128M -Xmx256M
    set JAVA_OPTS=-Xms128M -Xmx256M

其中 -Xms128M 为最小内存,-Xmx256M 为最大内存。

优化程序:

  • 检查代码中是否有死循环或递归调用。
  • 检查是否有大循环重复产生新对象实体。
  • 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
  • 检查 List、Map 等集合对象是否有使用完后,未清除的问题。List、Map 等集合对象会始终存有对对象的引用,使得这些对象不能被 GC 回收。

主要包括避免死循环,应该及时释放种资源:内存, 数据库的各种连接,防止一次载入太多的数据。导致的根本原因是程序不健壮。因此,从根本上解决Java内存溢出的唯一方法就是修改程序,及时地释放没用的对象,释放内存空间。 遇到该错误的时候要仔细检查程序,嘿嘿,遇多一次这种问题之后,以后写程序就会小心多了。

tomcat:java.lang.OutOfMemoryError:PermGen space

PermGen space:内存的永久保存区域(Permanent Generation space),这块内存主要是被 JVM 存放 Class 和 Meta 信息,Class 在被 Loader 时就会被放到 PermGen space 中, 它和存放类实例(Instance)的 Heap 堆区不同,GC 不会在主程序运行期对 PermGen space 进行清理,所以如果应用中有很多 Class 的话,就很可能出现 PermGen space 错误, 这种错误常见在 Web 服务器对 JSP 进行 pre compile 的时候。如果 Web APP 下都用了大量的第三方jar, 其大小超过了 JVM 默认的大小(4M)那么就会产生此错误信息了。

解决:修改 TOMCAT_HOME/bin/catalina.sh 的 MaxPermSize 大小:

echo “Using CATALINA_BASE: $CATALINA_BASE”

在上面加入:JAVA_OPTS="-server -XX:PermSize=64M -XX:MaxPermSize=128m

建议:将相同的第三方 jar 文件移置到 tomcat/shared/lib 目录,减少 jar 文档重复占用内存。

内存泄漏排查

内存分配

这里所说的内存分配,主要指在堆上的分配。一般的,对象的内存分配都是在堆上进行,但现代技术也支持将对象拆成标量类型(标量类型即原子类型,表示单个值,可以是基本类型或 String 等),然后在栈上分配,在栈上分配的很少见,这里不考虑。

Java 的内存分配和回收机制概括起来就是:分代分配,分代回收。

根据存活时间,对象被分为:新生代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation)。

三代

三代——《成为JavaGC专家part I》

新生代

对象被创建时,内存的分配首先发生在新生代(大对象可以直接被创建在老年代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被新生代的 GC 机制清理掉,这个 GC 机制被称为 Minor GC 或叫 Young GC。

注:Minor GC 并不代表新生代内存不足,它只是表示在 Eden 区上的 GC。

新生代分为3个区域:

  • 较大 Eden 区(伊甸园,亚当和夏娃偷吃禁果生娃娃的地方,用来表示内存首次分配的区域,再贴切不过)
  • 较小 两个大小相等的存活区(Survivor0、Survivor1)。

新生代内存分配过程:

新生代内存分配过程——《成为JavaGC专家part I》

  1. 绝大多数刚创建的对象会被分配在 Eden 区,其中的大多数对象很快就会消亡。Eden 区是连续的内存空间,因此在其上分配内存极快;
  2. 当 Eden 区满的时候,将执行 Minor GC 清掉消亡的对象,并将剩余的对象复制到存活区 Survivor0(此时,Survivor1 空,因为两个 Survivor 总有一个为空);
  3. 下次 Eden 区满了,再执行一次 Minor GC 清掉消亡的对象,将存活的对象复制到 Survivor1 中,然后清空 Eden 区;
  4. 将 Survivor0 中消亡的对象清理掉,将其中可以晋级的对象晋级到 Old 区,将存活的对象也复制到 Survivor1 区,然后清空 Survivor0 区;
  5. 当两个存活区切换了几次之后(HotSpot 虚拟机默认15次,用 -XX:MaxTenuringThreshold 控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值),仍然存活的对象(其实只有一小部分,比如我们自己定义的对象),将被复制到老年代。

从上面的过程可以看出,Eden 区是连续的空间,且 Survivor 总有一个为空。经过一次 GC 和复制,一个 Survivor 中保存着当前还活着的对象,而 Eden 区和另一个 Survivor 区的内容都不再需要了,可以直接清空,到下一次 GC 时,两个 Survivor 的角色再互换。因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将 Eden 区和一个 Survivor 中仍然存活的对象复制到另一个 Survivor 中)。不过,它也只在新生代下高效,如果在老年代仍然采用这种方式,则不再高效。

在 Eden 区,HotSpot 虚拟机使用了两种技术来加快内存分配:

  • bump-the-pointer:由于 Eden 区是连续的,因此改技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度。

  • TLAB(Thread-Local Allocation Buffers):该技术是对于多线程而言的,将 Eden 区分为若干段,每个线程使用独立的一段,避免相互影响。TLAB 结合 bump-the-pointer 技术,将保证每个线程都使用 Eden 区的一段,并快速地分配内存。

老年代(Old Generation)

对象如果在新生代存活了足够长的时间而没有被清理掉(即在几次 Minor GC 后存活了下来),则会被复制到老年代,老年代的空间一般比新生代大,能存放更多的对象,在老年代上发生的 GC 次数也比新生代少。当老年代内存不足时,将执行 Major GC 或叫 Full GC。

-XX:+UseAdaptiveSizePolicy:是否采用动态控制策略。如果动态控制,则动态调整堆中各个区域的大小以及进入老年代的年龄。

如果对象比较大(比如长字符串或大数组),新生代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前 GC,应少用,更应避免使用短命的大对象)。

-XX:PretenureSizeThreshold:控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。

可能存在老年代对象引用新生代对象的情况,如果需要执行 Minor GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。解决方法:老年代中维护一个 512 byte 的块 —— card table,所有老年代对象引用新生代对象的记录都记录在这里。Minor GC 时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

回收机制

新生代

新生代使用“停止-复制”算法进行清理,每次进行清理时,将 Eden 区和一个 Survivor 中仍然存活的对象拷贝到另一个 Survivor 中,然后清理掉 Eden 和刚才的 Survivor。

停止复制算法中,用来复制的两部分并不总是相等的(传统的停止复制算法两部分内存相等,但新生代中使用1个大的 Eden 区和2个小的 Survivor 区来避免这个问题)

由于绝大部分的对象都是短命的,甚至存活不到 Survivor 中,所以 Eden 区比 Survivor 大,HotSpot默认是 8:1,即分别占新生代的80%,10%,10%。如果一次回收中,Survivor + Eden 中存活下来的内存超过了10%,则需要将一部分对象分配到老年代。用 -XX:SurvivorRatio 参数来配置 Eden 区域 Survivor 区的容量比值,默认是8,代表 Eden:Survivor1:Survivor2 = 8:1:1。

老年代

老年代存储的对象比新生代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,老年代用的算法是标记-整理算法:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。

在发生 Minor GC 时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小:

  • 大于:直接触发一次 Full GC。
  • 小于:查看是否设置了 -XX:+HandlePromotionFailure(允许担保失败):如果允许,则只会进行 Minor GC,此时可以容忍内存分配失败;如果不允许,则仍然进行 Full GC。这表示如果设置了不允许担保失败,则触发 Minor GC 就会同时触发 Full GC,哪怕老年代还有很多内存,所以最好不要这样做。

方法区(永久代)

永久代的回收有两种:

  • 常量池的常量:没有引用了就可以被回收。
  • 无用的类信息,需满足3点:类的所有实例都已经被回收、加载类的 ClassLoader 已经被回收、类对象的 Class 对象没有被引用(即没有通过反射引用该类的地方)。

垃圾收集器

在 GC 机制中,起重要作用的是垃圾收集器,垃圾收集器是 GC 的具体实现,JVM 规范中对于垃圾收集器没有任何规定,所以不同厂商实现的垃圾收集器各不相同,HotSpot 1.6 版使用的垃圾收集器如下图(两个收集器之间有连线,说明它们可以配合使用):

垃圾收集器——《深入理解Java虚拟机:JVM高级特效与最佳实现》

注:在新生代采用的停止复制算法中,“停止(stop-the-world)”表示在回收内存时,需要暂停其他所有线程的执行。这很低效,现在的各种新生代收集器越来越优化这一点,但仍然只是将停止的时间变短,并未彻底取消停止。

注意并发(Concurrent)和并行(Parallel)的区别:

  • 并发是指用户线程与 GC 线程同时执行(不一定是并行,可能交替,但总体上是在同时执行的),不需要停顿用户线程(其实在 CMS 中用户线程还是需要停顿的,只是非常短,GC 线程在另一个 CPU上 执行);
  • 并行收集是指多个 GC 线程并行工作,但此时用户线程是暂停的。

Serial 串行,Parallel 并行,CMS 并发,G1 既可以并行也可以并发。

Serial 收集器

  • 新生代收集器
  • 停止复制算法
  • 单线程串行 GC,暂停其它工作线程

-XX:+UseSerialGC:开启 Serial + Serial Old 进行内存回收

Serial 收集器是虚拟机在 Client 模式下默认的新生代收集器,其收集效率大约是100M左右的内存需要几十到100多毫秒。收集桌面应用的内存垃圾,基本上不影响用户体验。所以一般的 Java 桌面应用中,使用默认的 Serial 收集器即可。

ParNew 收集器

Serial 收集器的多线程版本,默认开通的线程数与CPU数量相同。

  • 新生代收集器
  • 停止复制算法
  • 多个线程并行 GC,暂停其它工作线程,Serial 收集器的多线程版,缩短垃圾收集时间

-XX:+UseParNewGC:开启 ParNew + Serial Old 进行内存回收

-XX:ParallelGCThreads:设置执行内存回收的线程数

-XX:SurvivorRatio

-XX:PretenureSizeThreshold

-XX:+HandlePromotionFailure

-XX:MaxTenuringThreshold

Parallel Scavenge 收集器

  • 新生代收集器
  • 停止复制算法
  • 关注 CPU 吞吐量,即运行用户代码的时间/总时间,比如:JVM 运行100分钟,其中运行用户代码99分钟,垃圾收集1分钟,则吞吐量是99%,能最高效率地利用CPU,适合后台数据运算

-XX:+UseParallelGC:开启 Parallel Scavenge + Serial Old 进行内存回收(Server 模式下的默认设置)

-XX:GCTimeRatio:设置用户执行时间占总时间的比例,默认99,即1%的时间用来进行垃圾回收

-XX:MaxGCPauseMillis:设置 GC 的最大停顿时间(该参数只对 Parallel Scavenge 有效)

-XX:+UseAdaptiveSizePolicy:设置自适应调节策略,如自动调整 Eden/Survivor 比例,老年代对象年龄,新生代大小等

Serial Old 收集器

一般用在 Client 模式。

  • 老年代收集器
  • 标记整理算法:Sweep(清理)和 Compact(压缩)。Sweep 是将废弃的对象清掉,只留幸存的对象;Compact 是移动对象将空间填满保证内存分为2块:一块全是对象、一块空闲
  • 单线程串行 GC,暂停其它工作线程
  • JDK 1.5 前,Serial Old + ParallelScavenge 进行内存回收

Parallel Old 收集器

  • 老年代收集器
  • 标记整理算法:Summary(汇总)和 Compact(压缩)。Summary 是将幸存的对象复制到预先准备好的区域,而不是像 Sweep 那样清理废弃的对象
  • 多线程并行 GC,暂停其它工作线程
  • 有利于多核计算

-XX:+UseParallelOldGC 开启 Parallel Scavenge + Parallel Old 进行内存回收

在 JDK 1.6 后,Parallel Old + Parallel Scavenge 配合有很好的效果,能充分体现 Parallel Scavenge 收集器吞吐量优先的效果。

CMS(Concurrent Mark Sweep)收集器

  • 老年代收集器
  • 关注最短回收停顿时间(即缩短垃圾回收的时间),强调用户交互体验
  • 标记清除算法
  • 并发收集(用户线程可以和 GC 线程同时工作),停顿小

标记清除算法执行过程:(2次标记,1次预清理,1次重新标记,再1次清除)

  1. 初始标记(CMS-initial-mark)
  2. 并发标记(CMS-concurrent-mark)
  3. 预清理(CMS-concurrent-preclean)
  4. 可控预清理(CMS-concurrent-abortable-preclean)
  5. 重新标记(CMS-remark)
  6. 并发清除(CMS-concurrent-sweep)
  7. 并发重设状态等待下次 CMS 的触发(CMS-concurrent-reset)

-XX:+UseConcMarkSweepGC:开启 ParNew + CMS + Serial Old 进行内存回收。Server 模式下优先使用 ParNew + CMS,当用户线程内存不足发生 Concurrent Mode Failure 时,由备用方案 ParNew + Serial Old 收集

-XX:CMSInitiatingOccupancyFraction

-XX:+UseCMSCompactAtFullCollection

-XX:CMSFullGCsBeforeCompaction

CMSIncrementalMode: CMS 收集器变种,属增量式垃圾收集器,在并发标记和并发清理时交替运行垃圾收集器和用户线程。

G1(Garbage First)收集器

  • 堆被划分成许多个连续的区域(region)
  • G1 算法
  • 支持很大的堆,高吞吐量
  • 支持多 CPU 和垃圾回收线程
  • 在主线程暂停的情况下,使用并行收集
  • 在主线程运行的情况下,使用并发收集
  • 实时目标:可配置在N毫秒内最多只占用M毫秒的时间进行垃圾回收

–XX:+UseG1GC:开启 G1 进行内存回收

JVM 调优参数

http://kenwublog.com/docs/java6-jvm-options-chinese-edition.htm

性能参数:往往用来定义内存分配的大小和比例。

参数及其默认值描述
-XX:NewSize=2.125m新生代对象生成时占用内存的默认值
-XX:MaxNewSize=size新生成对象能占用内存的最大值
-XX:MaxPermSize=64m方法区所能占用的最大内存(非堆内存)
-XX:PermSize=64m方法区分配的初始内存
-XX:MaxTenuringThreshold=15对象在新生代存活区切换的次数(坚持过 Minor GC 的次数,每坚持过一次,该值就增加1),大于该值会进入老年代
-XX:MaxHeapFreeRatio=70GC 后 Java 堆中空闲量占的最大比例,大于该值,则堆内存会减少
-XX:MinHeapFreeRatio=40GC 后 Java 堆中空闲量占的最小比例,小于该值,则堆内存会增加
-XX:NewRatio=2新生代内存容量与老生代内存容量的比例
-XX:ReservedCodeCacheSize=32m保留代码占用的内存容量
-XX:ThreadStackSize=512设置线程栈大小,若为0则使用系统默认值
-XX:LargePageSizeInBytes=4m设置用于 Java 堆的大页面尺寸
-XX:PretenureSizeThreshold=size大于该值的对象直接晋升入老年代(这种对象少用为好)
-XX:SurvivorRatio=8Eden 区域 Survivor 区的容量比值,如默认值为8,表示 Eden:Survivor0:Survivor1 = 8:1:1

常用的行为参数:用来选择使用什么样的垃圾收集器组合,以及控制运行过程中的 GC 策略等。

参数及其默认值描述
-XX:-UseSerialGC启用串行 GC,即采用 Serial + Serial Old 模式
-XX:-UseParallelGC启用并行 GC,即采用 Parallel Scavenge + Serial Old 收集器组合(Server 模式默认)
-XX:GCTimeRatio=99设置用户执行时间占总时间的比例(默认值99,即1%的时间用于 GC)
-XX:MaxGCPauseMillis=time设置 GC 的最大停顿时间(这个参数只对 Parallel Scavenge 有效)
-XX:+UseParNewGC使用 ParNew + Serial Old 收集器组合
-XX:ParallelGCThreads设置执行内存回收的线程数,在 +UseParNewGC 的情况下使用
-XX:+UseParallelOldGC使用 Parallel Scavenge + Parallel Old 组合收集器
-XX:+UseConcMarkSweepGC使用 ParNew + CMS + Serial Old 组合并发收集
-XX:-DisableExplicitGC禁止调用 System.gc() 但 JVM 的 gc 仍有效
-XX:+ScavengeBeforeFullGC新生代 GC 优先于 Full GC 执行

常用的调试参数:用于监控和打印 GC 的信息。

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

启动内存分配

具体配置多少?设置小了,频繁 GC(甚至内存溢出),设置大了,内存浪费。建议:

-XX:PermSize:尽量比 -XX:MaxPermSize 小,-XX:MaxPermSize >= 2 x -XX:PermSize, -XX:PermSize > 64m,对于4G内存的机器,-XX:MaxPermSize 一般不超过256m。

-Xms = -Xmx(线上 Server 模式):以防止抖动,大小受操作系统和内存大小限制,如果是32位系统,则一般设置为1g~2g(假设有4g内存),在64位系统上,没有限制,一般为机器最大内存的一半左右。

-Xmn:在开发环境下,-XX:NewSize-XX:MaxNewSize 设置新生代的大小;在生产环境下,建议只设置 -Xmn,并且大小是 -Xms 的1/2左右,不要过大或过小,过大导致老年代变小,频繁 Full GC,过小导致 Minor GC 频繁。如果不设置 -Xmn,可以设置 -XX:NewRatio=2,效果一样。

-Xss:默认值即可。

-XX:SurvivorRatio:8~10左右,推荐设置为10,即 Survivor 区的大小是 Eden 区的 1/10,因为对于普通程序,一次 Minor GC 后,至少98%-99%的对象,都会消亡,该设置能使 Survivor 区容纳下10-20次的 Minor GC 才满,然后再进入老年代,这个与 -XX:MaxTenuringThreshold 的默认值15次也相匹配的。如果设置过小,会导致本来能通过 Minor GC 回收掉的对象提前进入老年代,产生不必要的 Full GC;如果设置过大,会导致 Eden 区相应的被压缩。

-XX:MaxTenuringThreshold:默认15,也就是说,经过15次 Survivor 轮换(即15次 Minor GC)就进入老年代,如果设置过小,则新生代对象在 Survivor 中存活的时间减小,提前进入年老代,对于老年代比较多的应用,可以提高效率。如果设置过大,则新生代对象会在 Survivor 区进行多次复制,这样可以增加对象在新生代的存活时间,增加在新生代即被回收的概率。注意:设置了该值,并不表示对象一定会在新生代存活15次才被晋升进入老年代,它只是一个最大值,事实上,存在一个动态计算机制,计算每次晋入老年代的阈值,取阈值以 MaxTenuringThreshold 中较小的一个为准。

-XX:PretenureSizeThreshold:默认值即可。

监控工具

在 JVM 运行的过程中,为保证其稳定、高效,或在出现 GC 问题时分析问题原因,我们需要对 GC 进行监控。所谓监控,其实就是分析清楚当前 GC 的情况。其目的是鉴别 JVM 是否在高效的进行垃圾回收,以及有没有必要进行调优。

通过监控GC,我们可以搞清楚很多问题,如:

  • Minor GC 和 Full GC 的频率
  • 执行一次 GC 所消耗的时间
  • 新生代的对象何时被移到老生代以及花费了多少时间
  • 每次 GC 中,其它线程暂停(Stop the world)的时间
  • 每次 GC 的效果如何,是否不理想

jps

用于查询正在运行的 JVM 进程。

常用参数描述
-q只输出 LVMID,省略主类的名称
-m输出虚拟机进程启动时传给主类 main() 函数的参数
-l输出主类的全类名,如果进程执行的是 jar 包,输出 jar 路径
-v输出虚拟机进程启动时 JVM 参数

命令格式:jps [option] [hostid]

栗子:

1
2
3
$ jps -l
19688 sun.tools.jps.Jps
19610 com.zoctan.api.Application

上面的 vid 为 19610 的 api.Application 进程在提供 web 服务。

jstat

实时显示本地或远程 JVM 进程中类装载、内存、垃圾收集、JIT 编译等数据(如果要显示远程 JVM 信息,需要远程主机开启 RMI 支持)。如果在服务启动时没有指定启动参数 -verbose:gc,则可以用 jstat 实时查看 GC 情况。

常用参数描述
-class监视类装载、卸载数量、总空间及类装载所耗费的时间
-gc监听堆状况,包括 Eden 区、两个 Survivor 区、老年代、永久代等的容量,已用空间、GC 时间合计等
-gccapacity监视内容与 -gc 基本相同,但输出主要关注堆的各个区域使用到的最大和最小空间
-gcutil监视内容与 -gc 基本相同,但输出主要关注已使用空间占总空间的百分比
-gccause与 -gcutil 功能一样,但是会额外输出导致上一次 GC 产生的原因
-gcnew监视新生代 GC 状况
-gcnewcapacity监视内同与 -gcnew 基本相同,输出主要关注使用到的最大和最小空间
-gcold监视老年代 GC 情况
-gcoldcapacity监视内同与 -gcold 基本相同,输出主要关注使用到的最大和最小空间
-gcpermcapacity输出永久代使用到最大和最小空间
-compiler输出 JIT 编译器编译过的方法、耗时等信息
-printcompilation输出已经被 JIT 编译的方法

命令格式:jstat [option vmid [interval[s|ms] [count]]]

命令格式中 VMID 和 LVMID 说明:

  • 如果是本地虚拟机进程,VMID 和 LVMID 一致
  • 如果是远程虚拟机进程,VMID 格式:[protocol:][//]lvmid[@hostname[:port]/servername],如果省略 interval 和 count,则只查询一次。

栗子:搜集 vid 为 19600 的 Java 进程的整体 GC 状态,每1000ms收集一次,共收集3次。

1
2
3
4
5
$ jstat -gc 19600 1000 3
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
7680.0 7680.0 4386.2 0.0 48640.0 17858.7 128512.0 88.0 19456.0 18871.3 2304.0 2164.6 2 0.018 0 0.000 0.018
7680.0 7680.0 4386.2 0.0 48640.0 17858.7 128512.0 88.0 19456.0 18871.3 2304.0 2164.6 2 0.018 0 0.000 0.018
7680.0 7680.0 4386.2 0.0 48640.0 17858.7 128512.0 88.0 19456.0 18871.3 2304.0 2164.6 2 0.018 0 0.000 0.018
XXXC:该区容量,XXXU:该区使用量描述
S0CSurvivor0区容量(Survivor1区相同,略)
S0USurvivor0区已使用
ECEden 区容量
EUEden 区已使用
OC老年代容量
OU老年代已使用
PCPerm 容量
PUPerm 区已使用
YGCYoung GC(Minor GC)次数
YGCTYoung GC 总耗时
FGCFull GC 次数
FGCTFull GC 总耗时
GCTGC 总耗时

-gcutil 查看内存:

1
2
3
4
5
$ jstat -gcutil 19600 1000 3
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
57.11 0.00 36.72 0.07 96.99 93.95 2 0.018 0 0.000 0.018
57.11 0.00 36.72 0.07 96.99 93.95 2 0.018 0 0.000 0.018
57.11 0.00 36.72 0.07 96.99 93.95 2 0.018 0 0.000 0.018

各列与用 gc 参数时基本一致,不同的是这里显示的是已占用的百分比,如 S0 为 57.11,代表着 S0 区已使用了57.11%。

jinfo

查询当前运行的 JVM 属性和参数的值。

常用参数描述
-flag显示未被显示指定的参数的系统默认值
-flag-flag name=value: 修改部分参数
-sysprops打印虚拟机进程的 System.getProperties()

命令格式:jinfo [option] pid

jmap

显示当前堆和永久代的详细信息,如当前使用的收集器,当前的空间使用率等。

常用参数描述
-dump生成堆转储快照
-heap显示堆详细信息(只在 Linux/Solaris 下有效)
-F当虚拟机进程对 -dump 选项没有响应时,可使用这个选项强制生成dump快照(只在 Linux/Solaris 下有效)
-finalizerinfo显示在 F-Queue 中等待 Finalizer 线程执行 finalize 方法的对象(只在 Linux/Solaris 下有效)
-histo显示堆中对象统计信息
-permstat以 ClassLoader 为统计口径显示永久代内存状态(只在 Linux/Solaris 下有效)

命令格式:jmap [option] vmid

其中前面3个参数最重要,如:
查看对详细信息:sudo jmap -heap 309
生成 dump 文件: sudo jmap -dump:file=./test.prof 309
部分用户没有权限时,采用 admin 用户:sudo -u admin -H jmap -dump:format=b,file=文件名.hprof pid
查看当前堆中对象统计信息:sudo jmap -histo 309 该命令显示3列,分别为对象数量,对象大小,对象名称,通过该命令可以查看是否内存中有大对象;
有的用户可能没有 jmap 权限:sudo -u admin -H jmap -histo 309 | less

jhat

分析使用 jmap 生成的 dump 文件。

命令格式:jhat -J -Xmx512m [file]

jstack

生成当前 JVM 的所有线程快照,线程快照是虚拟机每一条线程正在执行的方法,目的是定位线程出现长时间停顿的原因。

常用参数描述
-F当正常输出的请求不被响应时,强制输出线程堆栈
-l除堆栈外,显示关于锁的附加信息
-m如果调用到本地方法的话,可以显示 C/C++ 的堆栈

命令格式:jstack [option] vmid

调优步骤

在调优之前,需要记住下面的原则:

  • 多数的 Java 应用不需要在服务器上进行 GC 优化
  • 多数导致 GC 问题的 Java 应用,都不是因为我们参数设置错误,而是代码问题
  • 在应用上线之前,先考虑将机器的 JVM 参数设置到最优(最适合)
  • 减少创建对象的数量
  • 减少使用全局变量和大对象
  • GC 优化是到最后不得已才采用的手段
  • 在实际使用中,分析 GC 情况优化代码比优化 GC 参数要多得多

GC 优化的目的:

  • 将转移到老年代的对象数量降低到最小
  • 减少 Full GC 的执行时间

为了达到上面的目的,需要:

  • 减少使用全局变量和大对象
  • 调整新生代的大小到最合适
  • 设置老年代的大小为最合适
  • 选择合适的 GC 收集器

真正熟练的使用 GC 调优,是建立在多次进行 GC 监控和调优的实战经验上的,进行监控和调优的一般步骤为:

  1. 监控 GC 状态
  2. 分析结果,判断是否需要优化
  3. 调整 GC 类型和内存分配
  4. 不断的分析和调整
  5. 全面应用参数

监控 GC 状态
使用各种 JVM 工具,查看当前日志,分析当前 JVM 参数设置,并且分析当前堆内存快照和 GC 日志,根据实际的各区域内存划分和 GC 执行时间,判断是否进行优化。

分析结果,判断是否需要优化
如果各项参数设置合理,系统没有超时日志出现,GC 频率不高,GC 耗时不高,那么没有必要进行 GC 优化;如果 GC 时间超过1-3秒,或者频繁 GC,则必须优化。
注:如果满足下面的指标,则一般不需要优 GC:

  • Minor GC 执行时间不到50ms;
  • Minor GC 执行不频繁,约10秒一次;
  • Full GC 执行时间不到1s;
  • Full GC 执行频率不算频繁,不低于10分钟1次。

调整 GC 类型和内存分配
如果内存分配过大或过小,或者采用的 GC 收集器比较慢,则应该优先调整这些参数,并且先找一台或几台机器进行测试,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择。

不断的分析和调整
通过不断的试验和试错,分析并找到最合适的参数。

全面应用参数
如果找到了最合适的参数,则将这些参数应用到所有服务器,并进行后续跟踪。

调优实例

实例1

原作者发现部分开发测试机器出现异常:java.lang.OutOfMemoryError: GC overhead limit exceeded。该异常表示:GC 为了释放很小的空间却耗费了太多的时间,其原因一般有两个:堆太小,死循环/大对象。

因为这个应用有在线上运行,所以首先排除第2个原因,如果应用本身有问题,线上早就挂了,所以怀疑开发测试机器中堆设置太小。

使用 ps -ef | grep java 查看发现运行的程序带有这些参数:
-Xms768m -Xmx768m -XX:NewSize=320m -XX:MaxNewSize=320m

该程序较大,需要占用的内存也比较多。但堆区设置只有768m,而机器内存有2G,机器上只跑这一个 Java 应用,没有其他需要占用内存的地方。

通过上面的情况判断,只需要增大堆中各区域的大小即可,于是改成下面的参数:
-Xms1280m -Xmx1280m -XX:NewSize=500m -XX:MaxNewSize=500m

跟踪运行情况发现,相关异常没有再出现。

实例2

http://www.360doc.com/content/13/0305/10/15643_269388816.shtml

一个服务系统,经常出现卡顿,分析原因,发现 Full GC 时间太长:

1
2
3
$ jstat -gcutil:
S0 S1 E O P YGC YGCT FGC FGCT GCT
12.16 0.00 5.18 63.78 20.32 54 2.047 5 6.946 8.993

分析上面的数据,发现 Young GC 执行了54次,耗时2.047秒,每次 Young GC 耗时37ms,在正常范围,而 Full GC 执行了5次,耗时6.946秒,每次平均1.389s,表明问题是:Full GC 耗时较长。

分析该程序的参数发现:NewRatio = 9,也就是说,新生代和老生代大小之比为1:9,这就是问题的原因:

  • 新生代太小,导致对象提前进入老年代,触发老年代发生 Full GC
  • 老年代较大,进行 Full GC 时耗时较大

调整比例 NewRatio = 4,发现卡顿现象减少,Full GC 没有再发生,只有 Young GC 在执行。这就是把对象控制在新生代就清理掉,没有进入老年代(这种做法对一些应用是很有用的,但并不是对所有应用都要这么做)。

类加载机制

JVM 类加载机制分为:加载,验证,准备,解析,初始化。

类加载机制

加载

这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。

注:不一定从 Class 文件获取,既可以从 ZIP 包中读取(比如 jar 包和 war 包),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)。

验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

准备

正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。

注意这里所说的初始值概念,比如一个类变量定义为:

1
2
3
// 变量 v 在准备阶段过后的初始值为 0 而不是 8080
// 将 v 赋值为 8080 的 putstatic 指令是程序被编译后,存在类构造器<client>方法中
public static int v = 8080;

但如果声明为:

1
2
3
// 在编译阶段会为变量 v 生成 ConstantValue 属性
// 在准备阶段,虚拟机会根据 ConstantValue 属性将 v 赋值为 8080
public static final int v = 8080;

解析

该阶段指虚拟机将常量池中的符号引用替换为直接引用的过程。

符号引用就是 Class 文件中的 CONSTANT_Class_info、CONSTANT_Field_info、CONSTANT_Method_info 等类型的常量。

符号引用和直接引用的概念:

  • 符号引用与虚拟机实现的布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
  • 直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有直接引用,那引用的目标必定已经在内存中。

初始化

该阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。

初始化阶段是执行类构造器方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证方法执行之前,父类的方法已经执行完毕。

注意:如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法。

注意以下几种情况不会执行类初始化:

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  • 定义对象数组,不会触发该类的初始化。
  • 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  • 通过类名获取 Class 对象,不会触发类的初始化。
  • 通过 Class.forName 加载指定类时,如果指定参数 initialize为false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  • 通过 ClassLoader 默认的 loadClass 方法,不会触发初始化动作。

类加载器种类

从 JVM 的角度,只有两种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):该类加载器由 C++ 语言实现(HotSpot),是虚拟机自身的一部分。

  • 其他的类加载器:这些类加载器由 Java 语言实现,独立于虚拟机外部,并且全部继承自 java.lang.ClassLoader。

从开发者的角度,类加载器可以细分为:

  • 启动类加载器:负责将 Java_Home/lib 下面的类库加载到内存中(比如 rt.jar)。由于启动类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

  • 标准扩展(Extension)类加载器:由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现,负责将 Java_Home/lib/ext 或者由系统变量 java.ext.dir 指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。

  • 应用程序(Application)类加载器:由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现,负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。由于该类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统(System)加载器。

除此之外,还有自定义的类加载器,它们之间的层次关系被称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。

类加载器的双亲委派模型

双亲委派

双亲委派模型过程

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

使用双亲委派模型的好处在于 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存在在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的 Bootstrap ClassLoader 进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,那系统中将会出现多个不同的 Object 类,程序将混乱。因此,如果开发者尝试编写一个与 rt.jar 类库中重名的 Java 类,可以正常编译,但是永远无法被加载运行。

双亲委派模型的系统实现

在 java.lang.ClassLoader 的 loadClass() 方法中,先检查是否已经被加载过,若没有加载则调用父类加载器的 loadClass() 方法,若父加载器为 null 则默认使用启动类加载器作为父加载器。如果父加载失败,则在抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected synchronized Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException{
//check the class has been loaded or not
Class c = findLoadedClass(name);
if(c == null) {
try{
if(parent != null) {
c = parent.loadClass(name,false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch(ClassNotFoundException e) {
//if throws the exception ,the father can not complete the load
}
if(c == null) {
c = findClass(name);
}
}
if(resolve) {
resolveClass(c);
}
return c;
}

注:双亲委派模型是 Java 设计者推荐给开发者的类加载器的实现方式,并不是强制规定的。大多数的类加载器都遵循这个模型,但也有较大规模破坏双亲模型的情况,比如线程上下文类加载器(Thread Context ClassLoader),具体可参见周志明著《深入理解Java虚拟机》。

Author

Zoctan

Posted on

2018-07-24

Updated on

2023-03-14

Licensed under