深入理解 Java 虚拟机-学习笔记

我所践行的知识整理方法是“将思考具象化”,思考本是外界不可知的,其过程如何、其结果如何只有你自己心里才清楚。如果不把自己思考的内容输出给他人,很容易就会被自己所欺骗,误以为自己已经理解得足够完备了。

开篇中,我便提到了撰写这门课程的目的:做技术不仅要去看、去读、去想、去用,更要去说、去写。将自己“认为掌握了的”知识叙述出来,能够说得清晰有条理,讲得理直气壮;能够让他人听得明白,释去心中疑惑;能够把自己的观点交予别人的审视,乃至质疑,在此过程之中,会挖掘出很多潜藏在“已知”背后的“未知”。

上面是本书作者周志明老师的一段话, 我十分认可这样的学习方法, 这也是我写博客的目的: 既是分享, 又是自检.

如无特殊说明, 默认使用 HotSpot 虚拟机, 目前最常见的java虚拟机.

内存区域

image-20230902125517344

java 内存分为图中几个区域, 各个区域的功能如下:

  • 方法区: 储存常量值, 静态变量, 类信息

  • 堆: 储存对象

  • 栈: 用于记录方法的调用情况. 因为一层一层的方法调用和返回很像数据结构的"栈"而得名. 栈中的一个元素称为"栈帧", 栈帧中包含局部变量表等数据.

    局部变量表包含方法中基本数据类型和引用, 如果一个方法内用很多这样的局部变量, 这样一个方法对应的栈帧也会很大.

    hotspot 将虚拟机栈和本地方法栈合二为一了

  • 程序计数器: 指向下一条要执行的指令在内存中的位置, 通过改变它的值可以实现顺序执行/分支/循环/异常处理的效果.

其中, 栈和程序计数器是线程私有的. 而堆和方法区是所有线程共享的.

直接内存是在堆外分配的内存, 在一些 io 场景下可以避免数据在堆内存和本地(native)内存之间的拷贝开销. 它不受 gc 的管理, 所以需要手动释放.

垃圾回收

如何判断对象的存活

引用计数法:

给对象添加一个引用计数器,每当有一个地方引用它时计数器加1,引用释放时计数减1,当计数器为0时可以回收。

引用计数算法实现简单,判断高效,但在主流的Java虚拟机中没有使用该方法,因为无法解决对象相互循环引用的问题。如果有两个对象互相引用, 即使他们都已经不可达了, 但是他们会因为计数器不为0而一直存在

可达性算法:

可达性算法从一系列被称为"gc roots"的对象出发, 通过引用关系构建一个图, 在图里的就是还存活的对象, 其他的对象就是应该被回收的对象. 原理如下图.

image-20230902134443507

即使 object 567 之间仍然存在互相引用, 但是他们实际上已经不可能被使用了, 所以应该被回收.

gc roots 包括:

  • 栈帧的局部变量表中引用的对象
  • 静态属性对象
  • 方法区中常量引用的对象

四种引用

  • 强引用: 就是最常见的Object obj = new Object(), 就算 oom, gc 也永远不会回收这类引用指向的对象

  • 软引用(SoftReference): 有用但是非必须的对象, 如果即将 oom, gc 会先回收软引用指向的对象, 如果内存还不够才会 oom. 适合做缓存.

    SoftReference<Object> softReference = new SoftReference<>(new Object())

  • 弱引用(WeakReference): 比软引用更弱一些, 无论内存是否足够, gc 时都会被回收

  • 虚引用(PhantomReference): 它无法像软引用和弱引用一样获取对象, 也不会对对象的生存时间有任何的影响. 它的唯一目的就是常结合 ReferenceQueue 使用, 用于监控某个对象的回收事件

在软引用, 弱引用的构造方法里, 可以选择传入一个 ReferenceQueue 对象(虚引用则必须传入), 这个队列的作用是: 在对象被回收后, 一个对应的 Reference 对象会被添加进这个队列中, 你可以通过 poll(不阻塞) 或 remove(阻塞)来获取这个被回收的对象对应的 Reference, 你可以做一些事情, 比如释放一些相关资源.

垃圾回收算法

  • 标记-清除算法: 缺点是会产生大量内存碎片

    image

  • 复制算法: 将内存分为相同大小的两块, 一块满了就把存活的对象赋值到另一块, 这样能保证内存的规整, 缺点是内存利用率只有 50%

    image-20230902183426772

  • 标记-整理: 将存活的对象向同一方向移动, 可以保证内存的规整, 而且不需要额外的内存空间. 但是这样要更新所有对象的引用地址, 这必须暂停所有用户程序的执行.

    image-20230902183632150

在 gc 时, 把堆内存分为几个区域:

image-20230902184015516

新生代是指新创建的对象, 这些对象中 98% 都活不过第一轮 gc.

老年代是指熬过多轮 gc 的对象, 它们大概率还会继续存活.

针对不同区域, 采用不同的回收算法:

由于新生代中只有少部分对象能在 gc 中存活, 所以复制算法的效率就显得很高, 而且我们没有必要将内存划分为相等的两块, 事实上 Eden 和 两个 survivor 的大小比例是 8 : 1 : 1. 平时在分配内存时使用 Eden 和一个 survivor, gc 的时候就把存活的对象移动到另一个 survivor, 然后清理刚才的两块内存. (少数情况下, 新 survivor 可能装不下存活的对象, 这时候就把老年代当做担保内存)

由于老年代的大部分对象都会继续存活, 复制代价过高, 标记清除算法导致的内存碎片又会影响内存分配, 所以老年代采用标记整理算法.

对象晋升为老年代的情况

  1. 年龄计数:每个对象在年轻代中创建时,都会被初始化一个年龄计数器(初始为 1 岁)。每经历一次垃圾回收(通常是Minor GC),对象的年龄会增加。当对象的年龄达到一定阈值时(由-XX:MaxTenuringThreshold 参数决定, 默认是15),它会被晋升到老年代。
  2. 动态年龄: 如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
  3. 空间限制:使用复制算法时, 另一个survivor的空间不足. 把无法容纳的对象直接送入老年代.
  4. 大对象:非常大的对象通常会被直接分配到老年代,以避免在年轻代中产生大量的复制操作。这个阈值是参数-XX:PretenureSizeThreshold决定的

类加载的时机

类只会加载一次

  1. new 对象时
  2. 使用非 final 的静态字段时
  3. 调用静态方法时
  4. 使用反射时
  5. 初始化子类, 发现父类没有被初始化过, 会先去初始化父类
  6. 程序入口主类(包含 main 方法)
  7. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSp ecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  8. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类中的 static 代码块会在类加载时被执行, 且只会执行一次.

类加载机制

虚拟机接收的输入是字节码(.class 文件)而不是 java 代码, 这意味着只要你能用 class 的格式去定义类, 都能被 jvm 执行. 这也为其他 jvm 语言(如 kotlin)的出现奠定了基础.

类的加载是在程序运行期间发生的, 由于 class 文件的来源可以是多种多样的(网络/程序动态生成), 所以加载出来的类具有非常大的灵活性. 动态代理, mybatis 的 mapper 等高级技术都依赖此特性.

双亲委派机制

这个"双"很容易让人产生误解, 实际上叫做"父加载器优先机制"可能更加贴切.

在加载一个类的时候, 类加载器会优先让自己的父加载器去加载. 如果没有父加载器或者父加载器无法加载, 才会由自己来加载. 这实现了类加载的优先级. 比如我自己写了一个 java.lang.String 类, 但是 jvm 只会加载 java 自带的 String 类而不是我的假 String 类.

image-20230903140656410

泛型

Java 5 才出现泛型, 为了保证向后兼容, 即以前没有泛型的代码仍然能在新版本的 java 中运行, java 采取了一种简单粗暴的方式去实现泛型: 泛型仅体现在编码过程中, 编译后和运行中是没有"泛型"的概念的.

例如下面这段代码在编译后就丢失了泛型信息(又称为"泛型擦除"), 利用反编译工具得到的结果得到的第二段代码中多了几个强制类型转换.

 public static void main(String[] args) {
     Map<String, String> map = new HashMap<String, String>(); map.put("hello", "你好");
     map.put("how are you?", "吃了没?"); 
     System.out.println(map.get("hello")); 
     System.out.println(map.get("how are you?"));
 }
 public static void main(String[] args) {
     Map map = new HashMap();
     map.put("hello", "你好");
     map.put("how are you?", "吃了没?"); 
     System.out.println((String) map.get("hello")); 
     System.out.println((String) map.get("how are you?"));
 }

编译和解释

Java 在执行过程中其实同时发生着编译和解释.

解释器不需要额外的准备工作, 但是执行速度慢. 编译器需要把代码即时编译(jit)成 native 代码, 执行速度快.

jvm 会在解释的过程中检测执行频率高的热点代码, 然后将这一段代码编译为 native 代码以便后续使用编译执行.

java 内存模型

java 线程工作内存和主内存的关系可以用计组的 cache 和主内存的关系来理解. 事实上线程工作内存提出的目的也是为了利用 cache 来提高读写效率.

image-20230903194713722

image-20230903194720352

java 线程的工作内存内储存着线程使用到的变量的主内存副本(引用对象也是如此, 只不过拷贝过去的只有引用本身, 对象本身还在主内存中), 线程在读写变量时总是操作线程的工作内存, 如果改变了变量值, 在把变量刷回主内存之前, 其他线程是观察不到变化的.

volatile

  • 不保证原子性: 对 volatile 修饰的变量的操作不是原子性的, 例如自增操作, 我们不能保证读取->加一->写回这三件事是一气呵成的. 如果有多个线程同时对一个 volatile 变量进行自增操作, 结果还是会小于预期值.

  • 可见性: 被 volatile 关键字修饰的变量在被线程读取时, 会强制去主内存读取, 在被线程写时, 会强制在写入后刷回主内存. 保证了一个变量在不同线程之间的可见性.

  • 禁止指令重排: 根据计组流水线的知识, cpu 在执行指令时并不一定会按照顺序执行, 而是会在保证最终效果一致的前提下进行指令重排. 在单线程场景下可以这样做, 但是在多线程场景下可能会出现问题. 例如双重检查的单例模式中, 如果不给实例对象加 volatile, 可能会出现下图的情况. 而加了 volatile 就能保证 new 对象的 3 步是按照顺序执行的.

     public class Singleton {
         private volatile static Singleton instance;
         
         public static Singleton getInstance() { 
             if (instance == null) {
                 synchronized (Singleton.class) {
                     if (instance == null) {
                         instance = new Singleton(); 
                     }
                 } 
             }
             return instance; 
         }
     }
     
    

    在这里插入图片描述

Java 线程

java 线程是内核线程, 这意味着线程调度需要在用户态和内核态切换, 所以线程数量是有限的.

而 go 协程使用用户线程, 用户线程的调度在用户态完成, 内核无法感知, 实现复杂度更高, 开销更低.

由于不同系统的线程优先级设计并不同, 为 Java 线程设定优先级不一定能实现预期的效果, 不建议依赖这个功能.

Java 线程的 6 种状态

  • 新建: 还未开始执行
  • 运行: 包括运行中和等待抢占时间片两种状态
  • 无限期等待: 例如调用了 wait() 方法
  • 有限期等待: 例如调用了Thread.sleep()方法
  • 阻塞: synchronized
  • 结束: 已经结束执行

image-20230904093745301

锁优化

旨在降低线程同步的开销

锁升级

java 一开始只有重量级锁, 这种锁需要依赖操作系统的同步机制, 开销较大.

于是新版本的 java 会先尝试使用开销更小的锁. 实在不行再用重量级锁.

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  • 偏向锁: 优化依据是"一把锁经常被同一个线程反复获取", 可以在锁对象的对象头 markword 中记录偏向的线程 id, 在该线程下一次再来获取锁时, 无需任何同步操作, 直接执行.

    一旦出现另一个线程来尝试获取这把锁, 就会立即撤销偏向锁, 升级为轻量级锁

  • 轻量级锁: 优化依据是"大部分情况下, 都不会发生锁的竞争", 线程在进入同步代码块之前会在栈帧中建立一份lock record, 用于存储锁对象 mark word 的拷贝, 然后尝试用 CAS 将对象头 mark word 的指针更新为指向 lock record 的指针.

    如果更新成功说明没有竞争, 可以直接执行.

    如果更新失败, 说明锁已经被另一个线程抢到了, 此时升级为重量级锁.

编译优化

锁消除: 如果一段同步代码使用的锁对象不可能发生多线程竞争, 那么后端编译后, 执行这段代码时就不会出现同步操作.

锁粗化: 如果一段代码反复地获取同一把锁(例如在循环中反复获取), 那么编译器会扩大同步代码块的范围, 以减少加锁解锁的次数.

自旋锁

优化依据是"大部分情况下, 锁会很快地被释放"

当获取不到锁时, 线程可以不立即阻塞, 而是选择空转(自旋)一段时间等待锁的释放. 如果自旋成功了就可以直接开始执行而避免了阻塞-唤醒的开销, 如果达到了一定自旋次数还没有获取到, 就要阻塞了.

这个自旋次数是可以是适应性调整的, 如果这个锁对象刚刚被某个线程自旋成功, 那么针对这个锁对象的空转次数可以适当提高. 如果这个锁对象很少自旋成功, 那么自旋次数就会降低, 甚至不自旋.

文章作者: 白烛魁
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 白烛魁的小站
Java
喜欢就支持一下吧