JVM面试笔记

JVM篇

1-类加载运行全过程

其中 loadClass 的类加载过程有如下几步:

加载 >> 验证 >> 准备 >> 解析 >> 初始化 >> 使用 >> 卸载

  • 加载:在硬盘上查找并通过IO读入字节码文件,使用到类时才会加载,例如调用类的 main() 方法,new 对象等等,在加载阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口 ;

  • 验证:校验字节码文件的正确性 ;

  • 准备:给类的静态变量分配内存,并赋予默认值 ;

  • 解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如 main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过 程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用;

  • 初始化:对类的静态变量初始化为指定的值,执行静态代码块;

2-类加载器

类加载器:实现通过类的全限定名读取类的二进制字节流的代码块的加载器(在介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的 类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一 个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。)

  • 引导类加载器:负责加载支撑 JVM 运行的位于JRE的 lib 目录下的核心类库,比如 rt.jar、charsets.jar 等

  • 扩展类加载器:负责加载支撑 JVM 运行的位于JRE的 lib 目录下的 ext 扩展目录中的 JAR 类包

  • 应用程序类加载器:负责加载 ClassPath 路径下的类包,主要就是加载你自己写的那些类

  • 自定义加载器:负责加载用户自定义路径下的类包

3-双亲委派机制 

双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。

4-JVM组成区域及作用

JVM 包含两个子系统和两个组件,两个子系统为 Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到 Runtime data area 中的 methodarea。

  • Execution engine(执行引擎):执行 classes 中的指令。

  • Native Interface(本地接口):与native libraries 交互,是其它编程语言交互的接口。

  • Runtime data area(运行时数据区域):这就是我们常说的 JVM 的内存。

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

5-说一下运行时数据区

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分:

  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号 指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的 字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个 计数器来完成;

  • Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息;

  • 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚 拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;

  • Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享 的,几乎所有的对象实例都在这里分配内存;

  • 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变 量、即时编译后的代码等数据。

6-对象的创建

6.1-类加载检查

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

  • new 指令对应到语言层面上讲是,new 关键词、对象克隆、对象序列化等

6.2-分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从 Java 堆中划分出来。

这个步骤有两个问题:

  1. 如何划分内存。

  2. 在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

  • 划分内存的方法:

    • “指针碰撞”(Bump the Pointer)(默认用指针碰撞)

    • 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

    • “空闲列表”(Free List)

    • 如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

  • 解决并发问题的方法:

    • CAS(compare and swap)虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

    • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过­XX:+/­UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启­XX:+UseTLAB**),­XX:TLABSize 指定TLAB大小。

6.3-初始化

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

6.4-设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头 Object Header 之中。在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。 HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

6.5-执行 init 方法

执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

7-内存分配策略(对象内存分配)

7.1-对象栈上分配

我们通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

7.2-对象在 Eden 上分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。我们来进行实际测试一下。

在测试之前我们先来看看 Minor GC 和 Full GC 有什么不同呢?

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。

  • Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC 的速度一般会比 Minor GC的慢10倍以上。

  • Eden 与 Survivor 区默认8:1:1

大量的对象被分配在 eden 区,eden 区满了后会触发 minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块 survivor 区,下一次eden区满了后又会触发 minor gc,把 eden 区和 survivor 区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的 survivor 区,因为新生代的对象都是朝生夕死的,存活时间很短,所以 JVM 默认的8:1:1的比例是很合适的,让 eden 区尽量的大,survivor 区够用即可,JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy。

7.3-大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM 参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew 两个收集器下有效。比如设置 JVM 参数:-XX:PretenureSizeThreshold = 1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代。

为什么要这样呢?

为了避免为大对象分配内存时的复制操作而降低效率。

7.4-长期存活的对象进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS 收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数来设置。

7.5-对象动态年龄判断

当前放对象的 Survivor 区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块 Survivor 区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如 Survivor 区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了 Survivor 区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在 minor gc 之后触发的。

7.6-老年代分配担保机制

年轻代每次 minor gc 之前JVM都会计算下老年代剩余可用空间如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次 minor gc 后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次 Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"当然,如果 minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发 full gc,full gc 完之后如果还是没有空间放 minor gc 之后的存活对象,则也会发生“OOM“。

8-对象内存回收判断方法

8.1-引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。所谓对象之间的相互引用问题,如下面代码所示:除了对象obj-A 和 obj-B 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。

8.2-可达性分析算法

GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象;

GC Roots 根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等;

9-四种引用类型

java的引用类型一般分为四种:强引用软引用、弱引用、虚引用。

  • 强引用:普通的变量引用        

    public static User user = new User();
  • 软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。

    public static SoftReference<User> user = new SoftReference<User>(new User());
    

软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建

(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

  • 弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用

    public static WeakReference<User> user = new WeakReference<User>(new User());
  • 虚引用虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用

10-垃圾回收算法

10.1-分代收集理论

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。

10.2-标记-复制算法

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

10.3-标记-整理算法

根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

10.4-标记-清除算法

算法分为“标记”和“清除”阶段:标记存活的对象, 统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。它是最基础的收集算法,比较简单,但是会带来两个明显的问题:

  1. 效率问题 (如果需要标记的对象太多,效率不高)

  2. 空间问题(标记清除后会产生大量不连续的碎片)

11-垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的Java虚拟机就不会实现那么多不同的垃圾收集器了。

11.1- serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。

虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial Old收集器是 Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案**。

11.2- parallel 收集器

Parallel 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。Parallel Scavenge 收集器关注点是吞吐量(高效率的利用CPU)。CMS垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。ParallelScavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。新生代采用复制算法,老年代采用标记-整理算法。

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器(JDK8默认的新生代和老年代收集器)。

11.3-parnew收集器

ParNew收集器其实跟 Parallel 收集器很类似,区别主要在于它可以和 CMS 收集器配合使用。新生代采用复制算法,老年代采用标记-整理算法。

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

11.4- cms 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象速度很快

  • 并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。

  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三**色标记里的增量更新算法(见下面详解)做重新标记。**

  • 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。

  • 并发重置:重置本次GC过程中的标记数据。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面几个明显的缺点:

  • 对 CPU 资源敏感(会和服务抢资源);

  • 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次 gc 再清理了);

  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让 jvm 在执行完标记清除后再做整理

  • 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并**发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发 full gc,也就是"concurrentmode failure",此时会进入 stop the world,用serial old垃圾收集器来回收**

  • CMS的相关核心参数

    • -XX:+UseConcMarkSweepGC:启用cms

    • -XX:ConcGCThreads:并发的GC线程数

    • -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)

    • -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次

    • -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)

    • -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整

    • -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段

    • -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW

    • -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

11.5-G1收集器

11.6-ZGC收集器

12-深拷贝和浅拷贝

深拷贝创建一个指针和内存,将指针指向该内存

浅拷贝:在内存中创建一个指针指向已存在内存地址(只是增加了一个指针指向已存在的内存地址)

13-堆栈的区别

物理地址分配对象是否连续:堆的物理地址分配对象是不连续的,性能会慢些,所以有各种算法用于清理不再引用的对象,这些方法分别是标记-复制算法,标记-整理算法,标记-清除算法;栈的物理地址分配对象是连续的,性能快,因为栈使用的数据结构是栈,先进后出的原则,栈帧出栈内存内存随之进行收回;

内存分配的时间点:堆是在运行期间进行分配内存,栈是在编译期间分配内存的;

存放的内容:堆存放的是对象的实例和数组,该区域关注的是数据的存储;栈存放的是局部变量,操作数栈,动态链接,方法出口,该区域关注的是数据的处理;

程序的可见性:堆对于整个应用程序都是共享的、可见的;栈只对线程是可见的,也就是线程私有,它的生命周期和线程的生命周期相同;

14-堆的物理地址分配不连续的原因

  • 虚拟内存管理:现代操作系统使用虚拟内存来隔离进程和管理内存。每个进程都有自己的虚拟地址空间,内存管理单元(MMU)将这些虚拟地址映射到物理内存地址。这种映射使得物理内存可以不连续地分配给不同的进程。

  • 内存碎片:随着程序的运行,内存中的不同大小的块被分配和释放,导致内存碎片的产生。碎片可以分为内部碎片(分配的内存大于实际需求)和外部碎片(可用内存块不连续)。这种碎片化使得即使有足够的总内存可用,分配请求也可能无法获得连续的物理地址。

  • 多进程和多线程:在多进程或多线程环境中,多个程序或线程可能同时请求内存。由于不同程序可能在不同的时间请求内存,操作系统需要在物理内存中寻找可用的空间,这可能导致堆的物理地址分配变得不连续。

  • 内存管理策略:不同的内存分配算法(如首次适应、最佳适应或最差适应等)会影响内存块的分配方式。这些策略可能导致内存分配不连续,因为它们在选择内存块时可能并不考虑将物理内存区域保持连续。

15-JVM常见的调优参数

16-GC调优经验

17-Arthes的使用

18-JVM调优工具

19-三色标记

在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。这里我们引入“三色标记”来给大家解释下,把 Gc roots 可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:

  • 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。

  • 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。

  • 白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

  • 多标-浮动垃圾在并发标记过程中,如果由于方法运行结束导致部分局部变量(gc root )被销毁,这个 gc root 引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮 GC 不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色**,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。

  • 漏标-读写屏障

        漏标会导致被引用的对象被当成垃圾误删除,这是严重 bug,必须解决,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB) 。

  • 增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了

  • 原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

        以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

  • 写屏障

        给某个对象的成员变量赋值时,其底层代码大概长这样:

/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
  *field = new_value; // 赋值操作
}

         所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

void oop_field_store(oop* field, oop new_value) {
  pre_write_barrier(field); // 写屏障‐写前操作
  *field = new_value;
  post_write_barrier(field, value); // 写屏障‐写后操作
}
  • 写屏障实现SATB

    当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:

void pre_write_barrier(oop* field) {
  oop old_value = *field; // 获取旧值
  remark_set.add(old_value); // 记录原来的引用对象
}
  • 写屏障实现增量更新

    当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:

void post_write_barrier(oop* field, oop new_value) {
        remark_set.add(new_value); // 记录新引用的对象
}
  • 读屏障

    oop oop_field_load(oop* field) {
            pre_load_barrier(field); // 读屏障‐读取前操作
            return *field;
    }
    

    读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:

void pre_load_barrier(oop* field) {
  oop old_value = *field;
  remark_set.add(old_value); // 记录读取到的对象
}

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

CMS:写屏障 + 增量更新

G1,Shenandoah:写屏障 + SATB

ZGC:读屏障

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

为什么G1用SATB?CMS用增量更新?

我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

君子如玉zzZ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值