JVM扫盲:内存管理

1、关于Java

Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK,JDK是用于支持Java程序开发的最小环境。
Java API类库中的Java SE API子集和Java虚拟机这两部分统称为JRE,JRE是支持Java程序运行的标准环境。

HotSpot是目前使用最为广泛的虚拟机。

Java以后发展的几个方向:1).模块化,功能组件可插拔;2).混合语言:各不同的功能模块使用不同的运行在虚拟机之上的语言开发;
3).多核并行:使用分治算法等提升多核利用率;4).丰富现有的语法,5).解决64位虚拟机上的性能问题。

2、JVM内存管理

2.1 JVM内存区域

JVM扫盲:内存管理

上面的图展示的就是JVM运行时的数据区域。大致分成以上几个部分,我们这里对各个部分功能做简要的总结:

  1. 程序计数器:线程私有,用来指示当前线程所执行的字节码的行号,就是用来标记线程现在执行的代码的位置;
    对Java方法,它存储的是字节码指令的地址;对于Native方法,该计数器的值为空。
  2. :线程私有,一个方法的执行和退出就是用一个栈帧的入栈和出栈表示的,通常我们不允许你使用递归就是因为,方法就是一个栈,太多的方法只执行而没有退出就会导致栈溢出,不过可以通过尾递归优化。栈又分为虚拟机栈和本地方法栈,一个对应Java方法,一个对应Native方法。
  3. :用来给对象分配内存的,几乎所有的对象实例(包括数组)都在上面分配。它是垃圾收集器的主要管理区域,因此也叫GC堆。它实际上是一块内存区域,由于一些收集算法的原因,又将其细化分为新生代和老年代等。
  4. 方法区:方法区由多线程共享,用来存储类信息、常量、静态变量、即使编译后的代码等数据。运行时常量池是方法区的一部分,它用于存放编译器生成的各种字面量和符号引用,比如字符串常量等。

2.2 垃圾回收

根据上面JVM内存区域的描述,我们知道程序计数器和两种栈的生命周期与线程相同,线程或者方法结束即可回收。
所以,所谓的垃圾回收主要是针对方法区和堆内存而言。

生存还是死亡

可达性分析 四种引用类型 对象的自我救赎

判断一个对象是否可以回收通用的方式有两种。
一种是引用记数法,即给对象添加一个引用计数器,被引用时计数器加1,引用失效时减1。
这种方法不常用,因为它难以解决两个变量相互引用的问题。
另一种是可达性分析,即通过一系列GC Roots的对象作为起始点,从节点向下搜索,
当一个对象没有任何一条可到GC Roots的引用链,则该对象可回收。

根据可达性分析的原理,对象之间存在引用关系,但是“是或否被引用”不足以描述更多的场景,
所以在这基础之上人们又提出了四种引用类型的概念:强引用、软引用、弱引用和虚引用,它们的引用强度依次减弱。

当使用new关键字创建一个对象的时候,这个对象就是强引用的,它绝对不会被回收,即使内存耗尽。
你可以通过将其置为null来弱化对其的引用,但什么时候被回收还要取决于gc的算法。
软引用弱引用相似,你可以分别通过SoftReference<T>WeakReference<T>来使用它们,它们的区别仅在于后者更弱一些。
实际开发中,软引用使用较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出。
虚引用在任何时候都可能被垃圾回收器回收。

当一个对象不再被引用的时候,该对象也不一定被回收,理论上它还有一次救赎的机会,即通过覆写finilize()方法把对自己的引用从弱变强,即把自己赋值给全局的对象等。因为当对象不可达的时候,只有当finilize()没被覆写,或者finilize()已经被调用过,则该对象会被回收。否则,它会被放在一个队列中,并在稍后由一个低优先级的Finilizer线程执行它。所以,我们可以通过这个机制来完成救赎,但实际上没有必要那么干,因为覆写finilize()是不推荐的。

垃圾回收算法

标记-清除算法 复制算法 标记-整理算法 分代收集算法

JVM扫盲:内存管理

第一回收算法是标记-清除算法,这种算法直接在内存中把需要回收的对象“抠”出来。
好好的内存被它搞成了马蜂窝,所以效率不高,清除之后会产生内容碎片,造成内存不连续,当分配较大内存对象时可能会因内存不足而触发垃圾收集动作。

JVM扫盲:内存管理

复制算法将内存分成两块,一次只在一块内存中进行分配,垃圾回收一次之后,
就将该内存中的未被回收的对象移动到另一块内存中,然后将该内存一次清理掉。
比如将内存分成A和B,先在A中分配,当垃圾回收的时候把A中需要回收的内存清理掉,然后把不需要清理的所有对象复制到B里面。
复制算法常被用来回收新生代,而且分配空间也不是1:1,而是较大的Eden空间和较小的Survivor空间。在HotSpot中,其比例是8:1。

JVM扫盲:内存管理

类似于标记-清除算法,只是回收了之后,它要对内存空间进行整理,以使得剩余的对象占用连续的存储空间。

上面是三种基本的垃圾回收算法,但实际上,我们通常根据对象存活周期的不同将内存划分成几块,然后根据其特点采用不同的回收算法。
这就是所谓的分代收集算法

垃圾收集器

Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1

JVM扫盲:内存管理

HotSpot当*有7种垃圾回收器,按照它们负责区域,又可以分成三种:

  1. 新生代收集器:Serial、ParNew、Parallel Scavenge;
  2. 老年代收集器:Serial Old、Parallel Old、CMS;
  3. 整堆收集器:G1;

当搭配起来使用的时候又可以得到以下几种组合关系: Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1

下面我们按照这些收集器的相近关系来介绍以下它们:

  1. Serial收集器和Serial Old收集器:分别对应于新生代和老年代,都是单线程的,当进行垃圾回收的时候必须暂停其他工作线程,
    直到它收集结束,因此它们的性能会低一些。但它们也有自己的优势:简单高效。因没有线程交互,所以在单CPU环境中会有优势。
    当需要收集的垃圾比较少的时候,停顿时间会较小,因而是可以接受的。
    Serial收集器使用的是复制算法和Serial Old收集器使用的是标记-整理算法。

  2. ParNew收集器、Parallel Scavenge收集器和Paralle Old收集器:ParNew是Serial收集器的多线程版本,使用多线程收集垃圾;
    Parallel Scavenge相比于ParNew达到了吞吐量可控的目的,所谓吞吐量就是指运行用户代码的时间与CPU总耗时的比值,可以理解成执行你的代码的比例;
    而Paralle Old收集器可以看作Parallel Scavenge的老年版本。
    ParNew收集器和Parallel Scavenge收集器使用复制算法,而Paralle Old收集器使用标记-整理算法。

  3. CMS收集器:以获取最短回收停顿时间为目标,与其他收集器不同的是,它把垃圾收集过程分成了4个阶段:初始标记、并发标记、重新标记和并发清除。
    初始标记和重新标记过程需要Stop the world,但是它们耗时比较短,而耗时比较长的并发标记和并发清除则与用户线程同时进行。
    因而,它可以大大减少垃圾回收过程中的停顿时间。但它有几个缺点:1).CPU资源敏感,因为它的垃圾回收过程与用户线程同时执行,所以会占用用户线程的资源;
    2).无法处理浮动垃圾,所谓浮动垃圾就是在垃圾标记和回收的同时产生的垃圾,因为此时用户线程还在不断产生垃圾,所以这些垃圾只能到下次垃圾回收时才能被回收;
    3).因为它基于标记-清理算法,所以会造成磁盘空间碎片而不得不提前触发Full GC.

  4. G1收集器:G1收集器可以管理整个堆,它汲取了CMS收集器的优点而回避了它的缺点。
    它放弃了标记-清理算法,而使用标记-整理算法,同时建立了可预测的停顿时间模型。
    它的垃圾回收也分成四个过程:初始标记、并发标记、最终标记和筛选回收,而且它的标记过程也与用户线程同时进行,
    只是在回收阶段,它会根据用户期望的GC停顿时间指定回收计划,只回收一部分区域,从而提高收集的效率。

内存分配与回收策略

对象内存分配,往大方向上讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下可能直接分配在老年代。

  1. 对象优先在Eden分配:大多数情况下,对象在新生代Eden区非中分配,当Eden区没有足够空间时,虚拟机发起一次Minor GC。

  2. 大对象直接进入老年代:大对象指需要大量连续内存空间的Java对象,比如很大的数组或者字符串。经常出现大对象会导致内存还有不少空间时就提前触发垃圾收集来获取足够的连续空间来安置它们。
    虚拟机提供了-XX:PretenureSizeThreshold参数,当对象的大小大于它的值的时候将直接分配在老年代。这样做是为了避免在Eden区和Survivor区之间发生大量的内存复制。

  3. 长期存活对象将进入老年代:若对象出生在Eden区并经过一次Minor GC后仍然存活,并且能被Survivor容纳,将被移动到Survivor空间中,并且对象年龄将加1。
    在Survivor中,每熬过一次Minor GC,则年龄加1,当年龄达到一定程度时(默认15岁),就会被晋升到老年代。该年龄的阈值通过参数-XX:MaxTenuringThreshold设置。

GC日志

GC日志控制参数 日志含义

JVM的GC日志的主要参数包括如下几个:

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

比如:

-XX:+PrintGCDetails -Xloggc:../logs/gc.log -XX:+PrintGCTimeStamps  

GC日志:

0.256: [GC (System.gc()) [PSYoungGen: 2236K->824K(18432K)] 2236K->832K(60928K), 0.0023996 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.259: [Full GC (System.gc()) [PSYoungGen: 824K->0K(18432K)] [ParOldGen: 8K->742K(42496K)] 832K->742K(60928K), [Metaspace: 3069K->3069K(1056768K)], 0.0134299 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 

上面的GC日志中各个信息的意义如下:

  • 最前面的数字0.256和0.259表示的是GC发生的时间,是虚拟机启动以来经过的秒数;
  • [GC[Full GC表示停顿的类型,Full表示此次GC是发生了Stop-The-World
  • 当调用System.gc()触发GC的时候就会出现System.gc()
  • [PSYoungGen表示GC发生的区域,由收集器决定的。比如,PSYoungGen表示的是Parallel Scavenge收集器的新生代,[DefNew表示的是Serial收集器的新生代,ParNew表示的是Paralle收集器的新生代,对老年代和永久代同理;
  • 在方括号内的824K->0K(18432K)表示的是GC前该内存区域已使用量->GC后该内存区域已使用量(该内存区域总容量)
  • 在方括号外的2236K->832K(60928K)表示的是GC前该Java堆已使用量->GC后该Java堆已使用量(该Java堆总容量)
  • 0.0023996 secs表示该GC占用的时间。
  • [Times: user=0.02 sys=0.00, real=0.01 secs]中的时间分别表示:用户消耗的CPU时间、内核消耗的CPU时间和操作从开始到结束所经过的墙钟时间。

3、虚拟机性能监控与故障处理工具

放置在jdk的bin目录下面的可执行文件为我们提供了许多便利的工具。

3.1 jps:虚拟机进程状况工具

jps (JVM Process Status Tool),用来列出正在运行的虚拟机进程,并显示虚拟机执行主类名称及这些进程的本地虚拟机唯一ID。

命令格式:

jps [options] [hostid]

jps常用的选项

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

3.2 jstat:虚拟机统计信息监视工具

jstat (JVM Statistics Monitoring Tool),用于监视虚拟机各种运行状态信息,可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾回收、JIT编译等运行数,是运行期定位虚拟机性能问题的首选工具。

命令格式:

jstat [option vmid [interval [s|ms] [count]] ]

比如

jstat -gc 2764 250 20

表示:每250毫米查询一次进程2764的垃圾收集状况,一共查询20次。如果省略后面两个参数,则说明只查询一次。vmid可以通过jps获取到。

3.3 jinfo: Java配置信息工具

jinfo (Configuration info for java),用来实时查看和调整虚拟机各项参数。

命令格式:

jinfo [option] pid

3.4 jmap: Java内存映像工具

jmap (Memory Map for java),用于生成堆转储快照。jmap的作用并不仅仅为了获取dump文件,它还可以查询finalize执行队列、java堆和永久代的详细信息。如空间使用率、当前用的是哪种收集器等。

命令格式:

jmap [option] vmid

3.5 jhat:虚拟机堆转储快照分析工具

用来分析dump生成的堆快照,不过功能可以完全被其他工具取代,而且该工具在可视化方面也不好,所以可以略过。

3.6 jstack:java堆栈跟踪工具

jstack命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程死锁、死循环、请求外部资源导致长时间等待等。

命令格式:

jstack [option] vmid

参数:

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

3.7 HSDIS:JIT生成代码反汇编

3.8 JConsole

在jdk的bin目录下面找到该软件之后打开,非常方便地使用。可以使用它连接到本地或者远程的Java进程,并可以对内存、线程、类VM等进行实时监控。

3.9 VisualVM:多合一故障处理工具

它是位于jdk的bin目录下面的一个名为jvisualvm的可执行文件,打开它之后在左侧双击我们需要监控的进行即可对进行进行监控。

我们可以在“工具-插件”中选择需要安装的插件,而它的页面中的功能选项卡也是基于所安装的插件的。
从“工具-插件”中直接通过检查URL来获取插件已经行不通了,可以链接中下载指定jdk版本的插件,然后在“工具-插件-已下载”中进行安装。