六、GC、JVM基本知识

转自:https://juejin.im/post/5c95ffe5e51d454c896527cd

写在前面(常见面试题):https://blog.csdn.net/keyandi/article/details/89203476

基本问题

  • 介绍下 Java 内存区域(运行时数据区)
  • Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)

拓展问题

  • String类和常量池
  • 8种基本类型的包装类和常量池

1 概述

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为内一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

2 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。

这些组成部分一些是线程私有的,其他的则是线程共享的。

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存

2.1 程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.2 Java 虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack)其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有局部变量表、操作数栈、动态链接、方法出口信息)

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

2.3 本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

2.4 堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

推荐阅读:Java8内存模型——永久代(PermGen)和元空间(Metaspace)

2.5 方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

2.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)

既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。推荐阅读:Java 中几种常量池的区分

  • 1.全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。
  • 2.class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
  • 3.运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

        1.方法区里存class文件信息和class文件常量池是个什么关系。

        2.class文件常量池和运行时常量池是什么关系。        

        方法区存着类的信息,常量和静态变量,即类被编译后的数据。这个说法其实是没问题的,只是太笼统了。更加详细一点的说法是方法区里存放着类的版本,字段,方法,接口和常量池。常量池里存储着字面量和符号引用。

       符号引用包括:1.类的全限定名,2.字段名和属性,3.方法名和属性。

       下面一张图是我画的方法区,class文件信息,class文件常量池和运行时常量池的关系

       

       下面一张图用来表示方法区class文件信息包括哪些内容:

       

         可以看到在方法区里的class文件信息包括:魔数,版本号,常量池,类,父类和接口数组,字段,方法等信息,其实类里面又包括字段和方法的信息。

2.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

3 HotSpot 虚拟机对象探秘

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

3.1 对象的创建

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。

Java创建对象过程

1. 类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2. 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块内存。JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

3. 初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5. 执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充。

Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

3.3 对象的访问定位

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:

1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。通过句柄访问对象

2. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

通过直接指针访问对象

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

4 重点补充内容

4.1 String 类和常量池

1 String 对象的两种创建方式

1

2

3

String str1 = "abcd";

String str2 = new String("abcd");

System.out.println(str1==str2);//false

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

记住:只要使用 new 方法,便需要创建新的对象。

2 String 类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 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对象

3 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

结果:

1

2

false

true

解释:

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

4.2 8种基本类型的包装类和常量池

  • Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte、Short、Integer、Long、Character、Boolean;这5种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
  • 两种浮点数类型的包装类 Float、Double 并没有实现常量池技术。

1

2

3

4

5

6

7

8

9

Integer i1 = 33;

Integer i2 = 33;

System.out.println(i1 == i2);// 输出true

Integer i11 = 333;

Integer i22 = 333;

System.out.println(i11 == i22);// 输出false

Double i3 = 1.2;

Double i4 = 1.2;

System.out.println(i3 == i4);// 输出false

Integer 缓存源代码:

1

2

3

4

5

6

7

8

/**

 *此方法将始终缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。

 */

public static Integer valueOf(int i) {

    if (i >= IntegerCache.low && i <= IntegerCache.high)

        return IntegerCache.cache[i + (-IntegerCache.low)];

    return new Integer(i);

}

应用场景:

  1. Integer i1=40;Java 在编译的时候会直接将代码封装成 Integer i1=Integer.valueOf(40); 从而使用常量池中的对象。
  2. Integer i1 = new Integer(40) ;这种情况下会创建新的对象。

1

2

3

Integer i1 = 40;

Integer i2 = new Integer(40);

System.out.println(i1==i2); //输出false

Integer 比较(==)更丰富的一个例子:

1

2

3

4

5

6

7

8

9

10

11

12

13

Integer i1 = 40;

Integer i2 = 40;

Integer i3 = 0;

Integer i4 = new Integer(40);

Integer i5 = new Integer(40);

Integer i6 = new Integer(0);

 

System.out.println("i1=i2   " + (i1 == i2));

System.out.println("i1=i2+i3   " + (i1 == i2 + i3));

System.out.println("i1=i4   " + (i1 == i4));

System.out.println("i4=i5   " + (i4 == i5));

System.out.println("i4=i5+i6   " + (i4 == i5 + i6));  

System.out.println("40=i5+i6   " + (40 == i5 + i6));

结果:

1

2

3

4

5

6

i1=i2   true

i1=i2+i3   true

i1=i4   false

i4=i5   false

i4=i5+i6   true

40=i5+i6   true

解释:

语句 i4 == i5 + i6,因为 + 这个操作符不适用于 Integer 对象,首先 i5 和 i6 进行自动拆箱操作,进行数值相加,即 i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。

对于新生代如何晋升老年代有三种情况:

  • 经历了多次MinorGC后仍然存活
  • survivor区中存放不下大对象
  • 新生成的大对象

Java的垃圾回收判断对象是否为垃圾有两种算法。
1、引用计数算法,本算法就是通过引用计数器来统计,当被引用+1,完成引用-1.任何对象实例引用为0时就可以当做垃圾收集,优点是执行效率高,问题是循环引用会出现问题
2、可达性分析算法,通过判断对象的引用链来决定对象是否可达。如果不可达便被回收。可作为GC ROOT的对象有虚拟机栈中引用的对象、方法区中的常量引用的对象,方法区中的类静态属性引用的对象还有Native方法引用的对象,以及活跃线程的引用对象。

常见的垃圾回收算法

标记-清除算法
对存活对象进行标记,清除是从头到尾进行线性遍历,回收不可达的对象内存。问题时容易碎片化。

复制算法
分为对象面和空闲面。存活的对象从对象面复制到空闲面,同时将对象面的清空。优点是解决了碎片化的问题,顺序分配内存简单高效,适用于对象存活率低的场景。年轻代因为存活较少,所以适合于用复制算法

标记-整理算法
标记就是从GC ROOT进行扫描对存活对象进行标记,然后移动所有存活的对象,且按照内存地址依次有序排列,然后将末端的内存地址以后的内存都进行回收。优点是避免了内存的不连续行,且不用进行两块内存互换,适用于存活率高的场景。

分代收集算法
分代收集算法就是多种算法的集合,其中年轻代使用复制算法,老年代使用标记整理算法。JDK7之前有新生代,老年代和永久代。JDK7之后只有新生代和老年代没有了新生代。

年轻代分为eden区和两个servivor区,当进行垃圾回收时,存活的对象计数会加一,同时复制到servivor中的from区,然后将eden区清空,当第二次进行GC时eden区同理,但是servivor区仍然会计数加1然后复制到另一个servivor中。直到某个存活对象的技术超过某个值然后该对象进入老年代。
老年代常用的是标记-清除和标记整理算法,其中新生代老年代一半了比例为1:2

java中基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;

这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。

两种浮点数类型的包装类Float,Double并没有实现常量池技术。
public static final String A; // 常量A
public static final String B;    // 常量B
static {   
     A = "ab";   
     B = "cd";   
 }   
 public static void main(String[] args) {   
    // 将两个常量用+连接对s进行初始化   
     String s = A + B;   
     String t = "abcd";   
    if (s == t) {   
         System.out.println("s等于t,它们是同一个对象");   
     } else {   
         System.out.println("s不等于t,它们不是同一个对象");   
     }   
 } 
s不等于t,它们不是同一个对象

Object的finalize方法:

当这个对象的内存不再被使用时,GC在收集垃圾时就会调用这个方法,看起来就像是一个对象死亡之前的回调。但是GC只有在内存不足时才会进行垃圾回收,所以对象不一定总会被回收。

为什么要有这个方法:

存在垃圾回收器不能处理的特殊情况:分配内存的时候可能采用了类似 C语言的做法;或者打开的文件资源,这些资源不属于垃圾回收器的回收范围。

不推荐使用finalize,不保证执行的时机,不确定时机是因为你并不知道对象什么时候成为垃圾,系统什么时候进行垃圾收集

java虚拟机在进行垃圾回收的时候,一看到这个对象类含有finalize函数,就把这个函数交给FinalizerThread处理,而包含了这个finalize的对象就会被添加到FinalizerThread的执行队列,并使用一个链表,把这些包含了finalize的对象串起来。

只要finalize没有执行,那么这些对象就会一直存在堆区,不过这里只是4个包含了finalize的对象,影响不是那么大,如果有一万个或者是十万个呢?这就影响大了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值