JVM

本文详细阐述了Java内存区域的分布,包括栈、堆、方法区、本地方法栈和直接内存,并介绍了对象创建过程、内存分配、垃圾收集算法、类加载机制以及双亲委派模型。重点讨论了JVM如何保证线程安全地分配内存,以及不同垃圾收集器的工作原理和应用场景。同时,分析了字符串常量池的运作以及内存回收策略,特别是永久代到元空间的转变,以及如何判断对象和类是否无用。最后,探讨了类加载器的工作流程,以及类的生命周期。
摘要由CSDN通过智能技术生成

Java 内存区域

在这里插入图片描述
例如:String str = new String(“hello”);
上面的语句中变量str放在栈上,用new创建出来的字符串对象放在堆上,而"hello"这个字面量放在静态区。

程序计数器

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • 每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储。
  • 生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java 虚拟机栈

  • Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈。
  • 虚拟机栈中局部变量表主要存放了编译期可知的各种数据类型、对象引用。

本地方法栈

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

  • Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
  • jdk1.7开始,某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
  • Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆。

方法区

  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 方法区也被称为永久代。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。
  • 运行时常量池是方法区的一部分。

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  • 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  • 元空间里面存放的是类的元数据,由系统的实际可用空间来控制加载的类,可以加载更多。

直接内存

  • 直接内存的分配不会受到 Java 堆的限制,受到本机总内存大小以及处理器寻址空间的限制。
  • NIO(New Input/Output) 类可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,能在一些场景中显著提高性能。

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

2.分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩")。
在这里插入图片描述
创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

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

3.初始化零值:保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4.设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。

5.执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了;从 Java 程序的视角来看,对象创建才刚开始,init 方法还没有执行,所有的字段都还为零。执行 new 指令之后会接着执行 init 方法,一个真正可用的对象才算完全产生出来。

对象的访问定位

  • 建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。(主流两种访问方式)

  • 使用句柄(对象移动时reference 本身不需要修改):Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
    在这里插入图片描述

  • 直接指针(速度快): Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
    在这里插入图片描述
    常量池

  • String 对象的两种创建方式:

String str1 = "abcd";//先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";
String str2 = new String("abcd");//堆中创建一个新的对象
  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法
    它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7之前(不包含1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7以及之后的处理方式是在常量池中记录此字符串的引用,并返回该引用。
	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 对象
  • 字符串拼接:
  String str1 = "str";
  String str2 = "ing";
 
  String str3 = "str" + "ing";//常量池中的对象
  String str4 = str1 + str2; //在堆上创建的新的对象  
  • 例题:String s1 = new String(“abc”);这句话创建了几个字符串对象?
    创建 1 或 2 个字符串。如果池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

JVM 内存分配与回收

堆空间的基本结构:
在这里插入图片描述

  • 大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
  • 经过这次 GC 后,Eden 区和"From"区已经被清空。这个时候,“From"和"To"会交换他们的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To”。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,直到“To”区被填满,"To"区被填满之后,会将所有对象移动到老年代中。

分配担保机制

  • 给 对象分配内存的时候 eden 区内存几乎已经被分配完了,且eden 区中的对象无法存入 Survivor 空间,则把新生代的对象提前转移到老年代中去。

大对象直接进入老年代

  • 大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
  • 为什么要这样呢?
  • 为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

  • 部分收集 (Partial GC):
    新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
    老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
    混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集;
  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。

如何判断一个对象已经无效

  • 引用计数法
    给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。(它很难解决对象之间相互循环引用的问题)
  • 可达性分析算法
    这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

引用:强引用,软引用(可以和引用队列(ReferenceQueue)联合使用),弱引用,虚引用(必须和引用队列(ReferenceQueue)联合使用)

如何判断一个类是无用的类

  • 方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

  • 类需要同时满足下面 3 个条件才能算是 “无用的类” :
    1.该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    2.加载该类的 ClassLoader 已经被回收。
    3.该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

  • 虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾收集算法

  • 标记-清除算法
    该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
    存在问题:
    1.效率问题
    2.空间问题(标记清除后会产生大量不连续的碎片)
  • 标记-复制算法
    算法可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。
    解决了效率问题
  • 标记-整理算法
    标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
  • 分代收集算法
    当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
    可以回答面试问题: HotSpot 为什么要分为新生代和老年代?

垃圾收集器

  • Serial 收集器:是最基本、历史最悠久的垃圾收集器,是一个单线程收集器,它在进行垃圾收集工作的时候必须暂停其他所有的工作线程,直到它收集结束。
  • ParNew 收集器:是 Serial 收集器的多线程版本,使用多线程进行垃圾收集。
  • Parallel Scavenge 收集器:关注点是吞吐量(高效率的利用 CPU)。
  • CMS 收集器:一种以获取最短回收停顿时间为目标的收集器,关注点更多的是用户线程的停顿时间(提高用户体验)。
    是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
    是以“标记-清除”算法实现的,会有大量空间碎片产生。
    四个步骤:初始标记,并发标记,重新标记,并发清除
  • G1 收集器:一款面向服务器的垃圾收集器,主要针对配备多个处理器及大容量内存的机器, 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
    四个步骤:初始标记,并发标记,最终标记,筛选回收

minor gc运行的很频繁可能是什么原因引起的?

  • 产生了太多朝生夕灭的对象导致需要频繁minor gc
  • 新生代空间设置的比较小

minor gc运行的很慢有可能是什么原因引起的?

  • 新生代空间设置过大。
  • 对象引用链较长,进行可达性分析时间较长。
  • 新生代survivor区设置的比较小,清理后剩余的对象不能装进去需要移动到老年代,造成移动开销。
  • 内存分配担保失败,由minor gc转化为full gc
  • 采用的垃圾收集器效率较低,比如新生代使用serial收集器

类的生命周期

在这里插入图片描述

Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?

  • 系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。

  • 加载
    类加载过程的第一步,主要完成下面3件事情:
    1.通过全类名获取定义此类的二进制字节流
    2.将字节流所代表的静态存储结构转换为方法区的运行时数据结构
    3.在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

  • 加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

  • 验证
    文件格式验证,元数据验证,字节码验证,符号引用验证

  • 准备
    正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
    这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
    这里所设置的初始值"通常情况"下是数据类型默认的零值(如0、0L、null、false等),特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。

  • 解析
    虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

  • 初始化
    类加载的最后一步,也是真正执行类中定义的 Java 程序代码(字节码),初始化阶段是执行初始化方法 clinit()方法的过程。

  • 卸载
    即该类的Class对象被GC。(我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。)

类加载器总结

  • JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader:

  • BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。

  • ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。

  • AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。

双亲委派模型

  • 每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
  • 双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
  • 如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值