JVM 系列-1:虚拟机内存管理

本节主要介绍的内容有:Java 虚拟机的内存划分、垃圾回收机制、虚拟机内存分析工具。

1、关于 Java

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

Java以后发展的几个方向:

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

2、JVM 内存管理

2.1 JVM 内存区域

下图是 Java 虚拟机的内存主要区域划分,注意图中由浅蓝色标识的部分是所有线程共享的数据区;淡紫色标识的部分是每个线程私有的数据区域

我们这里对各个部分功能做简要的总结:

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

2.2 垃圾回收

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

生存还是死亡?

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

判断一个对象是否可以回收通用的方式有两种。

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

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

  1. 当使用 new 关键字创建一个对象的时候,这个对象就是强引用的,它绝对不会被回收,即使内存耗尽。你可以通过将其置为 null 来弱化对其的引用,但什么时候被回收还要取决于 GC 算法。
  2. 软引用和弱引用相似,你可以分别通过 SoftReferenceWeakReference 来使用它们,它们的区别在于后者更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象;而软引用关联着的对象,只有在内存不足的时候 JVM 才会回收该对象。软引用可以用来做缓存,因为当 JVM 内存不足的时候才会被回收;而弱引用适合 Android 上面引用 Activity 等的时候使用,因为 Activity 被销毁不一定是因为内存不足,可能是正常的生命周期结束。如果此时使用软引用,而 JVM 内存仍然足够,则仍然会持有 Activity 的引用而造成内存泄漏。
  3. 虚引用在任何时候都可能被垃圾回收器回收。

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

垃圾回收算法

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

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

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

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

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

垃圾收集器

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

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

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

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

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

CMS收集器:以获取最短回收停顿时间为目标,与其他收集器不同的是,它把垃圾收集过程分成了 4 个阶段:初始标记、并发标记、重新标记和并发清除。初始标记和重新标记过程需要 Stop the world,但是它们耗时比较短,而耗时比较长的并发标记和并发清除则与用户线程同时进行。因而,它可以大大减少垃圾回收过程中的停顿时间。但它有几个缺点:

  1. CPU 资源敏感,因为它的垃圾回收过程与用户线程同时执行,所以会占用用户线程的资源;
  2. 无法处理浮动垃圾,所谓浮动垃圾就是在垃圾标记和回收的同时产生的垃圾,因为此时用户线程还在不断产生垃圾,所以这些垃圾只能到下次垃圾回收时才能被回收;
  3. 因为它基于标记-清理算法,所以会造成磁盘空间碎片而不得不提前触发 Full GC.

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] 

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

  • 最前面的数字 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 版本的插件,然后在 “工具-插件-已下载” 中进行安装。


Java 虚拟机系列文章

  • JVM 系列-1:虚拟机内存管理
  • JVM 系列-2:虚拟机执行子系统
  • JVM 系列-3:虚拟机内存模型与高效并发

本系列以及其他系列的文章均维护在 Github 上面:Github / Awesome-Java,欢迎 Star & Fork. 如果你喜欢这篇文章,愿意支持作者的工作,请为这篇文章点个赞!

你可能感兴趣的:(Java,基础,并发,爬虫)