《深入理解 JVM 虚拟机》 --- 看书笔记

时间:2022-12-29 10:29:32

1、JVM 内存溢出

  1、堆溢出:堆要不断的创建对象,如果避免了垃圾回收来清除这些对象,就会产生JVM内存溢出。一般手段是通过内存映像分析工具对Dump出来的堆转储快照进行分析,分清楚到底是内存泄露还是内存溢出。

  2、虚拟机栈和本地方法栈溢出:线程请求的栈深度大于虚拟机所允许的最大深度。或者虚拟机在扩展栈时无法申请到足够的内存空间。

  3、方法区和运行时常量池溢出:一个类要被垃圾回收器回收,判断条件是苛刻的。

  4、本机直接内存溢出。

2、垃圾回收

  首先判断对象是否已经死亡(不再被任何途径使用)

  1)、引用计数算法,给对象中加一个计数器,一个地方引用该对象,计数器值就加1,当引用失效时,计数器值 就减1。为0的时候就不可能再被使用。但是有个缺点,如果两个对象互相引用对方,就会造成无限循环而不会被回收(类似死锁的机制)。

  2)、可达性分析算法,通过一系列 GC Roots 对象作为起点,从这些节点往下搜索,成为链路。如果对象不再链路上,表示可回收。(例如我在方法栈的对象引用指向了堆里的对象实例,就是可达的。所有所有渠道都没有指向堆里的对象实例,就是不可达,该对象就可以被回收)

  3)、方法区(永生代)垃圾回收:废弃常量和无用的类。

  垃圾收集算法

  1)、标记 - 清除算法(最基础),首先标记出所有需要回收的对象,标记完成后统一回收被标记的所有对象。但是有不足的地方:效率太低和清楚后会产生大量不连续的内存碎片。

  2)、复制算法,将可用内存分为大小相等的两块,每次只使用其中一块。使用完一块后,就将还活着的对象复制到另一块内存,清空第一块内存。缺点是代价太高昂。

  3)、标记 - 整理算法:将标记的死亡对象,都往一端移动,然后清理掉端边界以外的内存。

  4)、分代收集算法:把 Java 堆分为新生代和老年代。新生代每次垃圾收集都有大批对象死去,那就用复制算法。老年代就用标记 -整理算法。

  垃圾回收器

  1)、CMS 收集器,获取最短回收停顿时间为目标的收集器。基础算法用的 标记 - 清除 算法来实现的。整个过程分为4个步骤:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记,也要暂停其他工作线程。耗时最长的并发标记和并发清除可以和用户的线程同时工作。

  有点:并发收集、低停顿。

  适用场景:应用需要更快的响应,更短的停顿、CPU资源也比较丰富,就适合用 CMS 收集器。

  2)、G1收集器,可以并行和并发收集、分代收集、空间整合、可预测的停顿。

3、虚拟机类加载机制

  Class 文件格式的储存结构只有两种数据类型,无符号数和表。

  无符号数:用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。

  表:多个无符号数或者其他表作为数据页构成的复合数据类型,所有表习惯以"info"结尾。

  虚拟机运行时,从Class文件的常量池获得对应的符号引用,再类创建或运行时解析、翻译到具体的内存地址。

  类加载机制:把描述类的数据,从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成被虚拟机直接使用的 Java 类型。并且,类型的加载、连接和初始化都在程序运行期间完成。(动态加载和动态连接)

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

  其中解析可以在初始化阶段之后再开始,支持 Java 语言的运行时绑定。

  初始化:遇到 new、getstatic、putstatic、invokestatic 这4条字节码指令时,如果类没有初始化过,则会出发其初始化。

  使用反射调用时,如果类没有进行过初始化,也会触发初始化。

  当初始化一个类时,该类的父类没有初始化,则先触发其父类的初始化。

  当虚拟机启动时,用户需要先指定一个主类(含 main() 方法的类,例如 springboot 的启动类),虚拟机会先初始化该类。

  加载:通过一个类的全限定名来获取此类的二进制字节流,然后将字节流代表的静态存储结构转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

  验证

  1)、文件格式验证,验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。

  2)、元数据验证,对字节码进行语义分析,保证其描述的信息符合 Java 语言规范。

  3)、字节码验证。

  4)、符号引用过验证。

  准备:将类变量分配内存,并设置类变量初始值的阶段,这些变量所使用的的内存在方法区进行分配。类变量(被 static 修饰的变量),并且赋值动作是在初始化阶段才会执行。但如果用 final 修饰的话,准备阶段就会赋值。

  解析:将常量池内的符号引用,替换为直接引用(指向目标的指针或者是一个能间接定位到目标的句柄)。

  类加载器:每个类都有一个类加载器,组合起来成为 JVM 里面的唯一性。不同的类加载器,加载的同一个 Class 文件,得到的两个类也必定不相等。

  双亲委派模型:顶层为启动类加载器,然后下一级为扩展类加载器,再下一级为应用程序加载器,再下一级为自定义类加载器。除了顶层的启动类加载器,其余的类加载器都有自己的父类加载器。不是以父子关系存在,而是以组合关系来复用父加载器的代码。

  当一个类加载器收到类加载请求(自己未加载过),将求情往上委派给父类加载器,一直往上,到顶层启动类加载器。 当父类加载器无法完成这个加载请求,子加载器才会去加载。

  扩展知识

  系统的各种API类,用的启动类加载器加载。如果自己写相同的API类,包路径一样,双亲委派加载机制,就会让顶层父类加载器加载,但是加载的类有唯一性,所以会报错。

  如果没有双亲委派机制,子类加载器自己就加载了一个API类,并且父类也加载了系统API类,在引用的时候,就会不知道引用哪个API类而报错。

  new 一个对象时,在堆里面放了对象的实例,把类的元数据放入了方法区。

  方法区指向一个常量池,方法区指向一个对象,对象指向该常量池(常量池数据相等)。如果用==号比较方法区的两个不同的引用,地址不同,false。如果用 equal 比较,则相等。

  编译的时候,会折叠字符串,例如 String s = '"1" + "2"; 编译的时候 String s = "3"; 所以只会创建一个对象。

  对象逃逸:方法内创建的对象,作为返回参数返回出去后,被其他方法引用。或者对象作为参数,传入下级方法等操作。避免了该对象被标记为死亡的(方法结束,对该对象的引用就断开,就可以视该对象死亡,可用垃圾回收器回收)。所以叫对象逃逸。

4、JVM 调优

  类加载过程的调优:泛型擦除、自动拆装箱、遍历循环、变长参数、条件编译(将不符合条件的代码擦除)。

  实行分层编译:C1 和 C2 将同时工作,许多代码可能会被多次编译,C1获取更高的编译速度,C2获取更好的编译质量。

  热点代码:被多次调用的方法,被多次执行的循环体。基于采样的热点探测,检查各个线程的栈顶,某个方法多次出现,就是热点方法。还有基于计数器的热点探测,为每个方法建立计数器,统计方法的执行次数(HotSpot采用这种计数)。有个计数器热度的衰减,才能让计数器统计某个时间段,方法是否是热点方法。 而不是整个程序启动到关闭的时间。

  默认调用方法次数1500次触发C1,1W次触发C2。可以通过 -XX:CompileThreshold来设定。

  C1 编译器:简单的快速的三段式编译器,重点关注局部性的优化,放弃许多耗时较长的全局优化手段。

  对象逃逸:栈上分配、同步消除、标量替换

5、内存模型

  在计算机系统,加入了一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲。但是就引入了一个新的问题:缓存一致性

  在多处理器系统中,每个处理器都有子集的高速缓存,但是它们又共享同一主内存。

  解决缓存一致性问题,就抽象出来了:内存模型,即对特定的内存或高速缓存进行读写访问的过程抽象。

  除了增加高速缓存,处理器内部的运算单元能尽量被充分利用,处理器还可能对输入代码进行乱序执行优化。(结果和顺序执行的结果一致,但是不保证程序的各个语句计算的先后顺序和输入代码的先后顺序一致)

  所以一个计算任务依赖另一个计算任务的中间结果,其顺序性不能靠代码的先后顺序保证,那么JVM 的即时编译器也有了类似的 指令重排序 优化。

  内存模型

  所有变成存在主内存中,且每个线程还有自己的工作内存(类比 高速缓存),工作内存中保存了该线程使用到的变量的主内存副本拷贝。

  线程对变量的操作,都在工作内存中进行,不能直接操作主内存中的变量。线程之间变量值的传递,需要通过主内存来实现交互。

  volatile:JVM 提供的最轻量级的同步机制,对 volatile 变量的写操作,都能立刻反应到其他线程中。

  使用 volatile 变量的一个语义是禁止指令重排序优化。(加了内存屏障:重排序时不能把后面的指令,重排序到内存屏障之前的位置)

  当只有一个CPU访问内存时,不需要内存屏障。但是有多个CPU访问同一块内存,且其中一个在观测另一个,就需要内存屏障来保证数据一致性。

  long 和 double 非原子性协定:64位数据的读写可以分为两次32位的操作来进,而 long 和 double 就是64位的。多个线程共享一个 long 或者 double 类型的变量,并且同时进行读写操作,某些线程就可能读到非原值,也非其他线程修改的值,而是“半个变量”的值。(但是基本不会出现)

  目前商用虚拟机几乎都把64位数据的读写操作作为原子操作。

  原子性:如果需要更大范围的原子性保证,提供了 lock 和 unlock 操作,以及同步块 - synchroniezd 关键字。

  可见性:volatile 能保证新值能立即同步到主内存,以及每次使用前都从主内存刷新。 所以该线程对其他线程可见。(还有 synchornized 和 final 字段能保证)

  有序性:本线程内观察,所有线程都是有序的。一个线程观察另一个线程,所有操作都是无序的(主要是 指令重排序和主内存和工作内存同步延迟 两个原因,才是无序,所以 volatile 禁止指令重排序,变成了有序)。