关于JVM的简单BB

98 阅读11分钟

JVM

JVM对于Java代码而言,做了很多事情,其中最主要的工作个人认为是在两个层面。

其一:内存管理

先谈JVM的运行时内存吧,这个东西基本上面试都会问到,但是对于日常开发,基本上不会过多的注意,但是想要写出复杂、高级的代码,对这块还是要有一些基本的理解。

image-20240520215552624.png

这是一张基于Java1.8的JVM运行时内存图

共有部分即所有的线程都能访问,但是私有是每个线程自己独享的。

对于各个区域除了程序计数器以外,都会出现OOM即内存溢出的情况

  1. JVM虚拟机栈:栈的大小是有上限的,默认是1M大小,所以创建过多的局部变量可能撑爆栈空间,导致OOM;并且每一次压栈(可以简单理解为调用方法)都会消耗栈空间,因为每次压栈都可能会创建局部变量、操作数栈、动态连接等信息,这些都是需要占用栈空间的容量。过多的压栈操作可能导致StackOverflowError栈内存溢出。
  2. 本地方法栈:与JVM虚拟机栈类似
  3. 堆内存:堆内存可以简单理解为存放new出来的对象的地方,所以一旦超过堆内存的上限就会导致OOM,这里就涉及到最喜欢问又非常不实用的JVM垃圾回收,后文再表。
  4. 元空间:类被加载后存储的位置,基于Java1.8,永久代方法区统统被元空间包括取代,元空间使用的本地内存,所以空间很大,但是再大也有上限,同样会OOM

接下来聊聊JVM的垃圾回收过程

标准八股预备起:堆分为新生代和老年代,大小默认1:2,可调整,命令不记得,自己搜下;新生代又再划分为8:1:1的伊甸区(eden)和两个幸存者区(Survivor);

垃圾回收首先要知道什么是垃圾,JVM有两种方法发现垃圾,引用计数法、可达性分析

听着高大上,引用技术法就是对于创建的对象,维护一个引用次数字段,被引用一次+1,不为0就不是垃圾,为0就当垃圾回收;但是这个方法有个问题,A对象被B对象引用,但是B对象又A对象引用了,其他对象又不引用这两痴男怨女,就会导致这两一直纠缠,不会被当成垃圾处理掉;再说得直白一点,被问到背出来就行了,老古董,早没人用了。

可达性分析通过GC Roots来解决循环引用的问题,先解释下GC Roots是什么东西,一般由JVM虚拟机栈中的局部变量、元空间的静态变量、常量池的引用、本地方法栈的引用和直接内存中的某些区域来作为GC roots。通过遍历这些GC roots来分析对象可不可达,上述A-B相互引用的情况,如果没有GC roots能达到A-B,A、B也会被标记成垃圾,明白了没。

知道怎么发现垃圾、那么就要开始清理垃圾,清理垃圾由有一下几种垃圾回收算法

  1. 标记清除
  2. 标记复制
  3. 标记整理
  4. 分代回收

标记就是上面通过可达性分析发现垃圾的过程,标记清楚就是直接将标记的垃圾干掉,将内存释放出来,简单粗暴,会出现一个什么问题呢,打个比方,家里贴的地砖,有一些坏了,你直接给他敲了,那么现在的地面肯定一块好一块坏的,相互交错。为什么要说这个,是因为后面说到对象的创建过程会提到一个内存分配,后文再表。标记清除这么的简单粗暴,导致场面不好看,那么标记复制就是对他的增强,上文不是提到新生代又分为一个伊甸区和两个幸存者区么。在伊甸区内将标记的垃圾处理掉,剩下存活的对象就会被放到幸存者区的某一个,第一次两个幸存者区都是空的,所以没什么好说的,第二次这么操作,又冒出来一个from区和to区的概念,假设第一次回收后剩下的来的对象被放在幸存者区1,第二次就会将1里面和伊甸区剩下的放到幸存者区2,这样1、2分别就是from区和to区,下一次就要反过来,能理解吗?不知道谁定义的,挺扯的。注意,进入幸存者区的对象不是就长生不死了啊。幸存者区的内存空间也是有限的,当空间满了它也会进行GC的,这里再补充一个概念,经历GC又没有被回收的对象,它的age会+1,这个age>15的时候,它会被放进老年代,这个age的存放位置是在对象头里面,对象头是什么东西,后文再表。

分个段,上面那段写的太长了,标记复制这么看是不是挺好,每次都能整出来一个空的、连续的伊甸区内存空间,但是注意啊它这么搞一直有两个幸存者区得不到利用、只是单纯的作为容器存对象了,所以标记复制的毛病就是会导致部分内存得不到充分利用。标记整理登场,这货呢相当于标记复制之后,将内存进行一次排队,被占用的内存排一块,空闲出来的排一块,能理解吧。因为有这么整理内存的操作,所以他的缺点就是耗时长(复制的操作就不耗时了?但是他说这是缺点,你就记着)

分代收集就是新生代采用标记复制,老年代采用标记清除或者标记整理(重点记忆),Java1.8就是采用得分代收集算法

垃圾回收算法还有别的,但是这些应付面试应该够了,其他需要得自己再去找资料学习去吧

接着往下说,垃圾回收算法可以说是方法论,那么要让其变成可行得方案,就又要提到垃圾回收器

简单理解就是垃圾回收器对上述的理论进行了实现,约等于要写个方法计算a+b=c,你写了个方法达成了这个目的,这个比喻能理解吧。

image-20240520230059291.png

,上面是新生代,下面是老年代

Jdk8默认使用的是Parallel Scavenge GC+Parallel Old GC

1、 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

2、其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案。

3、(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214),即:移除。

4、(绿色虚线)JDK 14中:弃用Parallel Scavenge和SerialOld GC组合 (JEP 366)

5、(青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)

主要对这个的理解也有限,不输出浅薄的个人理解了。

JVM的运行内存以及一些相关知识就介绍到这里。

其二:类的加载

JVM通过类加载器将类从磁盘、网络等地方加载到JVM运行时内存当中,类加载器后续再表。

先谈一谈类的加载过程

  • 第一步:加载

    • 在加载步骤,JVM会将编译的字节码文件加载到JVM运行时内存的方法区,1.8之后就是元空间
  • 第二步:连接

    • 连接又分三步:

      • 校验:检验字节码文件是否合法,这个设计到字节码,本人水平有限说不清楚,想深入了解可以找找资料,可以参考尚硅谷
      • 准备:为类的静态变量分配内存初始化默认值,这里有个东西后面再说
      • 解析:将类中接口、方法等的符号引用转为直接引用
  • 第三步:初始化

    • 创建实例(new),对静态变量
  • 第四步:使用阶段

  • 第五步:卸载

对于静态变量的初始化,会在连接阶段的准备步骤,分配内存,基础类形就设置默认值,引用类形置为null,但是后续在初始化环节,可能会覆盖这个默认值。假设代码static int a = 1;准备阶段会给a分配内存,设置为0(int类型的默认值),初始化阶段JVM执行类的方法才会被赋值为1。

如果静态变量是final修饰的,准备步骤就会被赋值为1,初始化阶段会根据是否有具体的值,再决定要不要赋值。如果有值,初始化阶段不会做任何操作。

对于非静态变量,在类加载过程中不会被初始化,非静态变量是与类的实例相关联。

对于类的加载过程而言,类加载器起到一个搬用工的作用,当然加载过程也是由类加载器实现的。

类加载器的级别由高到低分别为:

1、启动类加载器BootstrapClassLoader

2、扩展类加载器ExtClassLoader

3、应用程序类加载器AppClassLoader

4、用户自定义类加载器CustomerClassLoader

说到这里又要引入一个新的概念:双亲委派机制

即进行类加载时,类加载器不会立即加载,而是委托他的父类进行加载,相当于应用类加载器加载某个类,会先交给扩展类加载器加载,扩展类又交给启动类,启动要是找到了类,直接返回该类,找不到就会由扩展类再尝试,找到直接返回,找不到应用类加载器自己加载这个类。

为什么要这么做?

  • 避免类的重复加载:每个类只会被加载一次,因为父类加载器会先尝试加载,确保了类的唯一性。
  • 保证核心类库的安全:系统类加载器无法加载核心库(如java.*包下的类),这些类由引导类加载器加载,保证了JVM的基础运行环境不受影响。
  • 模块化的隔离:开发者可以通过自定义类加载器实现特定的加载逻辑,而不会影响到JVM内置的类加载机制。

提一嘴,类加载过程是线程安全的,由JVM保证线程安全

现在填一个坑,类既然已经加载了,那么就可以创建对象了,开new。

创建对象实例的时候,首先要在堆内存中为对象分配内存空间,这空间大小是怎么确定的呢,根据对象头+对象实例+对其填充来决定需要的内存大小

对象头包括以下部分:

  1. 对象标记MarkWord

    ① 哈希值

    ② GC分代年龄

    ③ 锁状态标志

    ④ String类还有字符串长度

  2. 类型指针:类信息

对象实例包括:相关属性,如果继承父类,还有父类属性(含私有)

对齐填充:填充,可有可无,保证是加起来是8字节的倍数

对象的创建步骤如下

第零步:检查类有没有加载

第一步:为对象分配内存

有两种算法,这个跟之前的垃圾回收有关

对象碰撞:标记复制、标记整理GC之后的内存是连续有序的,直接再空闲内存的头部取用足够的内存空间

空闲列表:标记清除的内存空间是不连续的,对于清理出来的空闲内存维护一个空闲列表,使用时从中选取足够的内存空间,并从列表中移除被使用的内存地址

第二步:初始化默认值

第三步:对象头设置

第四步:构造器调用

第五步:引用赋值

第六步:垃圾回收

再提两个概念:

TLAB:多线程环境下对象的创建可能存在线程安全问题,这是由垃圾收集器(G1、CMS)提供的一种内存分配优化技术,线程开始时JVM为每个线程分配一个初始的TLAB内存区域,这个内存区域是从新生代扣的。当线程需要需要创建新对象时,先在自己的TLAB区域分配内存,因为每个线程都有自己的,所以不用加锁竞争,提高了效率。如果TLAB区域不足,会尝试扩容,扩容失败,JVM会采用其他措施(具体是啥我也不知道)。线程结束,这个线程的TLAB会被释放。

逃逸分析:对象不是绝对会分配在堆上,对于局部变量,根据作用域的分析可能会被分配在栈上。