【学习笔记】JVM

Java内存区域

第一章:Java内存区域

一、概述

在虚拟机自动内存管理机制的帮助下,程序员不需要为每一个new操作delete/free代码,不容易出现内存泄漏和内存溢出的问题。

二、运行时的数据区域

JVM在执行Java程序过程中会把它管理的内存划分成若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间。

A. 线程私有

  1. 程序计数器:Program Counter Register

    • 它是一块比较小的内存空间,可以看做当前线程所执行的字节码行号的指示器
    • 字节码解释器就是通过改变这个计数器的值来选取下一条要执行的字节码指令。
    • 由于JVM是通过线程轮流的方式切换并分配处理器去执行多线程的。因此,为了线程切换后能恢复到正确执行的位置,每条线程都有单独的程序计数器,保证各个线程之间互不影响。
    • 没有规定内存溢出的情况。
  2. 虚拟机栈:Virtual Machine Stacks

    它是用于描述Java方法执行的内存模型。

    • 栈帧:Stack Frame
      • 每个方法在执行时,都会创建一个栈帧。用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
    • 局部变量表:
      • 存放了各种基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)
      • 需要注意的是64位的long和double会占两 个局部变量空间,其余的数据类型都只占一个。
      • 局部变量表的内存空间在编译期完成分配。
    • StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度会发生栈溢出错误。
    • OutOfMerroryError:当虚拟机栈可以动态扩展时,扩展无法申请到足够的内存,就会抛出超出内存的错误。
  3. 本地方法栈 Native Method Stack

    它与虚拟机栈发挥的作用相似,不同在于虚拟机栈位虚拟机执行Java字节码服务,本地方法栈为虚拟机执行使用到的Native方法服务。

    • HotSpot虚拟机将虚拟机栈与本地方法栈合二为一。

B. 所有线程共享的数据区

  1. 堆:Heap

    它是用于存放对象实例(new),是所有线程共享的一块内存区域。

    • 在虚拟机启动时创建。
    • 垃圾收集器管理的主要区域。
    • 它可以是物理上不连续的一段内存空间,但是逻辑上要连续。
    • OutOfMerroryError:如果堆中没有内存完成实例分配,并且堆也无法扩展时,就会抛出超出内存的错误。
    • -xmx -xms 修改堆内存大小
  2. 方法区:Method Area

    它用于加载被虚拟机加载的类信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码等

    它一般很少进行垃圾收集的行为,主要的回收目标在于针对常量池的回收和对象类型的卸载。

    • 运行时的常量池:Runtime Constant Pool

      它是方法区的一部分,它用于存放编译期生成的各种字面量和符号引用,这个部分的内容将在类加载后进入方法区的运行常量池中保存。

      当常量池无法再申请到内存时,会抛出OutOfMerroryError

  3. 直接内存:Direct Memory

    它并不是虚拟机运行时数据区的一部分。

三、HotSpot虚拟机

以HotSpot虚拟机和堆为例,讨论对象分配、布局和访问的全过程。

A.对象创建

当JVM遇到new指令(创建对象):

  1. 首先会去检查这个指令是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否被加载、解析和初始化过。
  2. 如果没有,会先执行相应的类加载。
  3. 检查完类后,为新生对象分配堆内存。(对象所需的内存大小在类加载完成后便可确定)
  4. 将分配好的内存空间初始化为零值。
  5. 执行init方法,将对象按照意愿初始化。
  • 内存分配:指针碰撞、空闲列表
  • 线程安全:线程同步、本地线程分配缓冲

B. 内存对象

对象在内存中储存的布局分为3块部分:对象头(Header)、实例数据(Instance Data)、对象填充(Padding)。

  • 对象头 Header:包括两部分信息。
    1. 用于存储对象自身的自身运行时数据(哈希吗、GC分代年龄、锁状态标志等等)
    2. 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
  • 实例数据 Instance Data:对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
  • 对齐填充 Padding:不是必然存在的,也没有什么特别的含义,仅仅起占位作用。因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

C.访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。这个引用需要通过某种方式去定位、访问堆中对象的具体位置。

目前有两种主流方法分别为句柄法和直接指针。

  • 句柄法:Java堆中会分配出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含着对象的实例数据与类型数据各自的具体地址信息。
    • 稳定:当对象被移动时(GC会移动对象),只需改变实例数据的指针,而不需要修改reference

  • 直接法:Java堆的布局必须考虑如何放置访问类型数据信息,reference中储存的就是对象地址。
    • 节省了一次指针定位的时间开销,适用于对象频繁访问。

四、内容补充

A. String类和常量池

  1. String创建的两种方式
1
2
3
String str1 = "abcd";//常量池(位于方法区中)
String str2 = new String("abcd");//堆对象
System.out.println(str1==str2);//false

这两种不同的创建方法是有差别的,第一种方式是在常量池(位于方法区中)中拿对象,第二种方式是直接在堆内存空间创建一个新的对象。

  1. String 类型的常量池比较特殊。它的主要使用方法有两种:
    • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
    • 可以使用 String 提供的 intern 方法String.intern() 。它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
1
2
3
4
5
6
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量池中的String对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的String对
  1. String 字符拼接
1
2
3
4
5
6
7
8
9
String str1 = "str";
String str2 = "ing";

String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false

尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的花,可以使用 StringBuilder 或者 StringBuffer。

  1. String s1 = new String(“abc”);这句话创建了几个对象?

1
2
3
4
String s1 = new String("abc");// 堆内存的地值值
String s2 = "abc";
System.out.println(s1 == s2);// 输出false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出true

创建了两个对象。先有字符串”abc”放入常量池,然后 new 了一份字符串”abc”放入Java堆(字符串常量”abc”在编译期就已经确定放入常量池,而 Java 堆上的”abc”是在运行期初始化阶段才确定),然后 Java 栈的 str1 指向Java堆上的”abc”。

第二章:Java垃圾收集

为什么需要了解垃圾回收( Garbage Collection ,GC)和内存分配?当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,我们需要对这些“自动化”的技术实施必要的监控与调节。

一、概述

Java语言内存运行时的各区域可分为:

  • 线程私有(程序计数器、虚拟机栈、本地方法栈):随着线程生,随线程灭;栈种的栈帧已知(在确定类结构的时侯便得到内存大小)。
  • 共享(堆、方法区):一个接口种的多个实现类需要的内存不一样,一个方法中多个分支需要的内存也不一样,只有在程序运行时才知道创建哪些对象。
  • -verbose:gc
  • -xx:+PrintGCDetail

二、如何判断对象为垃圾对象

在堆中存放着几乎所有的对象实例,如何判断它们的存活与否至关重要。

A.引用计数 Reference Counting

  • 思想:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值+1;当引用计数失效时,计数器值-1;当计数器为0时,对象不可能再被使用。
  • 问题:当两个对象之间互相引用,虚拟机无法通过引用计数的方法判断是否存活。

B. 可达性分析算法 Reachability Analysis

  • 思想:通过一系列的称为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索锁走过的路径称为引用链(Reference Chain)。当某一个对象没有任何引用链相连,认为它不可用。
    • GC root对象:虚拟机栈中引用的对象、方法区的类属性引用的对象、方法区中常量引用的对象、Native方法引用的对象

三、垃圾收集算法

A.标记-清除算法

  • 步骤:分为“标记”与“清除”两个阶段。

    1. 标记出所有需要回收的对象;
    2. 回收被标记的对象。
  • 问题:

    • 效率问题:标记和清除过程效率都不高
    • 空间问题:会产生大量不连续的内存碎片,空间碎片太多可能会导致下一次分配较大对象时,因为内存不足出发新的GC。

B.复制算法

  • 解决效率问题,不用考虑内存碎片的问题。
  • 步骤:
    1. 它将内存按照容量划分成大小相等的两块,每次只使用一块。
    2. 当一块使用完了,就将还存活着的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。
  • 如果将内存容量按1:1的大小分割,会导致内存浪费的问题,为了解决这一问题:
  1. 将堆内存进行重新划分,其可以分为较大的Eden(伊甸园)和两个较小的Survivor(存活区),它们的比例为8:1:1。
    1. 每次GC时,将Eden中存活的对象复制到Survivor中。

C.标记-整理算法

  • 步骤:分为“标记”与“清除”两个阶段。
    1. 标记出所有需要回收的对象;
    2. 将存活对象向一段移动,然后直接清理掉端边界以外的内存。

D. 分代收集算法

  • 根据对象存活周期的不同将内存划分成几块,一般将Java堆分为新生代和老生代。
    • 新生代中:选择复制算法。
    • 老生代中:标记-清理或标记-整理算法。

四、垃圾收集器

垃圾回收算法是内存回收的方法论,而垃圾收集器则是内存回收的具体实现。图中展示了7种不同的垃圾回收器,如果两个收集器之间存在连线,说明它们可以搭配使用,收集器的位置说明它属于新生代还是老年代。

新生代收集器:

A. Serial收集器

  • 它是最基本、发展最悠久的收集器。
  • 它使用复制算法。它是一种单线程的收集器,它在工作时必须暂停其它所有工作线程,直到它收集结束。(Stop The World)
  • 简单、高效,没有线程交互的开销。
  • Client模式下的默认新胜达收集器。

B. ParNew收集器

  • ParNew是Serial收集器的多线程版本。
  • 使用了复制算法、多线程。
  • Server模式下首选的新生代收集器,它只和CMS收集器配合工作.
  • 不适合单核工作,效率不如Serial.

C. Parallel Scavenge 收集器

  • 复制算法、多线程
  • 其它收集器:尽可能缩短GC时,用户线程停止的时间。(绝对时间)
  • PS收集器:达到一个可控制的吞吐量。(相对时间)
    • 吞吐量=运行用户代码的时间/运行用户代码的时间+垃圾收集的时间。

老年代

D. Serial Old收集器

  • 是Serial的老年代版本,使用“标记—整理”算法。
  • 客户端使用。
  • 用途:
    1. 与Parallel Scavenge 搭配使用
    2. 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

E. Parallel Old收集器

  • 是Parallel Scavenge的老年代版本,使用“标记—整理”算法。

F. CMS(Concurrent Mark Sweep)收集器

  • 它是一种以获取最短回收停顿时间为目的的收集器。常用于服务端应用上,这类应用尤其重视服务器响应速度,给用户带来较好的体验。
  • 基于“标记—清除算法”,工作过程:
    1. 初始标记:标记GC Roots可以直接关联到的对象,速度快。
    2. 并发标记:进行可达性分析
    3. 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
    4. 并发清除
  • 优点:并发收集、低停顿。
  • 缺点:占用大量CPU资源(并发标记和并发清除)、无法处理浮动垃圾(在并发清除时产生的新垃圾)、出现Concurrent Mode Failure、磁盘碎片

G. G1收集器

  • 优势:
    • 并行与并发:G1能够充分利用多CPU、多核环境下的硬件优势,可以使用多个CPU来缩短停顿时间。
    • 分代收集:与其他收集器不同,它不在分新生代和老年代了,而将堆划分成多个大小相等的独立区域。
    • 空间整理:使用“标记-整理”算法实现,使得G1工作时不产生内存空间碎片。
    • 可预测的停顿:可以让使用者明确指定在长度M毫秒的时间段内,消耗在垃圾收集上的时间不超过N毫秒。
  • 步骤:
    1. 初始标记:标记GC Roots可以直接关联到的对象,速度快。
    2. 并发标记:进行可达性分析
    3. 最终标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变化的一部分标记记录。
      • 将这段时间对象的变化记录在线程Remember Set Logs中。
      • 将Remember Set Logs合并到Remember Set中。
    4. 筛选回收:将各Region的回收价值与成本进行排序,根据用户所期望的GC停顿时间进行回收。

五、内存分配策略

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会分配在老年代,分配规则并不是百分之百固定的,细节取决于当前使用的哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。

  • Minor GC:新生代垃圾收集,速度快
  • Major GC/Full GC:老年代垃圾收集,速度比Minor GC慢10倍

六大原则:

  1. 优先分配到Eden。
  2. 大对象直接分配到老年代(大对象指的是需要大量连续内存空间的JAVA对象,最典型的是很长的字符串和数组。)
  3. 长期存活的对象分配到老年代(通过对每个对象设置年龄计数器)
  4. 动态对象年龄判断(合理分配新生代和老年代)
  5. 空间分配担保

六、补充知识

A.引用类型

Java中的引用可以分为强引用、软引用、弱引用和虚引用。

  1. 强引用:这是最常见的对象引用。只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会回收它。

    • String strongReference = new String("abc"); //强引用
    • GC可能会回收强引用的情况,具体回收时机还是要看GC策略。
      • 超过了引用的作用域:在一个方法的内部有一个强引用(Java栈中),而真正的引用内容保存在Java堆中。 当这个方法运行完成后,就会退出方法栈,则引用内容的引用数0,这个对象会被回收。
      • 显式地将相应强引用赋值为 null,在ArrayList类中定义了一个elementData数组,在调用clear方法清空数组时,每个数组元素被赋值为null
  2. 软引用:它是相对强引用弱化一些的引用。只有当 JVM 认为内存不足OutOfMemoryError时,才会去试图回收软引用指向的对象。

    1
    2
    3
    // 软引用
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<String>(str);
    • 它可用来实现内存敏感的高速缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
    1
    2
    3
    4
    5
    6
    if(JVM内存不足) {
    // 将软引用中的对象引用置为null
    str = null;
    // 通知垃圾回收器进行回收
    System.gc();
    }
    • 当内存不足时,JVM首先将软引用中的对象引用置为null,然后通知垃圾回收器进行回收:

    • 应用场景:浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?

      1. 如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建;
      2. 如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。

      这时候就可以使用软引用,很好的解决了实际的问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 获取浏览器对象进行浏览
    Browser browser = new Browser();
    // 从后台程序加载浏览页面
    BrowserPage page = browser.getPage();
    // 将浏览完毕的页面置为软引用
    SoftReference softReference = new SoftReference(page);

    // 回退或者再次浏览此页面时
    if(softReference.get() != null) {
    // 内存充足,还没有被回收器回收,直接获取缓存
    page = softReference.get();
    } else {
    // 内存不足,软引用的对象已经回收
    page = browser.getPage();
    // 重新构建软引用
    softReference = new SoftReference(page);
    }
  3. 弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

    1
    2
    3
    String str = new String("abc");
    WeakReference<String> weakReference = new WeakReference<>(str);
    str = null;
    • 它可以用来构建一种没有特定约束的关系。例如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。
    • 实现缓存的方式。
  4. 虚引用:你不能通过它访问对象。

    • 虚引用主要用来跟踪对象被垃圾回收器回收的活动。
    • 用于监控对象的创建和销毁。

B.生存还是死亡

要宣告一个对象的死亡,至少需要两次标记的过程:

Step 1: 进行可达性分析。

  1. 如果发现当前对象没有GC Roots相连的引用链,那么它将会被第一次标记。
  2. 接着对标记的对象进行一个筛选(此对象是否有必要执行finalize方法)。
    • 没必要执行:对象没有覆写finalize方法或finalize被jvm调用过。
    • 有必要执行:其他。

Step 2:进行清理

  1. 如果对象被判定为“有必要执行finalize方法”,那么会将这个对象放置在F-Queue的队列之中。
  2. 一个由虚拟机自动建立的、低优先级的Finalizer线程去执行(清除)它,但并不承诺会等待finalize方法运行结束,因为可能会发生执行缓慢、死锁等问题。
  3. GC会对F-Queue中的对象进行第二次小规模的标记,如果对象重新与引用链上的任何一个对象建立起关联,那么GC将会将它移出即将回收的集合。
强烈不建议使用finalize方法,它的运行代缴高安,不确定性大,无法保证各个对象的调用顺序。如果要关闭外部资源,使用try-finally或其他方法即可。

下列代码演示:

  1. 对象可以在被GC时自我拯救
  2. 这种自救只有一次机会,因为一个对象的finalize最多只被系统自动调用一次。
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
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("i am alive");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次拯救自己
SAVE_HOOK = null;
System.gc();
//因为finalize优先级低,所以暂停0.5秒等待它
Thread.sleep(500);
if (SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("i am dead");
}
//与上面代码完全相同,但是失败了。
SAVE_HOOK = null;
System.gc();
//因为finalize优先级低,所以暂停0.5秒等待它
Thread.sleep(500);
if (SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("i am dead");
}
}
}

C.回收方法区

方法区,在HotSpot虚拟机中被称为永久代。它主要回收两部分内容:废弃常量和无用的类。

  • 废弃常量:举例来说,假设一个字符串“abc”进入常量池,但是没有其他任何String对象应用这个常量,那么这时发生GC时,必要的话会将“abc”清理出去。
  • 无用的类:
    • 该类的所有实例已经被回收,也就是Java堆中不存在该类的任何实例。
    • 加载该类的ClassLoader已经被回收。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过放射访问该方法。