JVM 第一次学习总结 --- 2019年4月
1、内存模型
起源:在计算机系统,加入了一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲。
问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存(抽象为工作内存),但是这些处理器又共享了同一主内存。
解决:抽象出来了 内存模型 ,即对主内存或工作内存的读写访问,用5个操作来实现不同线程之间的数据交互(通信交互)。(6个操作 read、load、use、assign、store、write)
引申出:6个操作不是原子性的,所以可能数据不安全,所以多线程的数据变化不确定,所以多线程一般不安全。
并且:除了加高速缓存,处理器内部的运算单元为了被充分利用,还对输入的代码进行了执行顺序的重排序。重排序:执行结果一致,不保证定义的代码的先后顺序执行,并且重排序只针对多个线程,或者前后代码之前没有依赖关系。如果线程之前彼此有依赖关系,或前后代码有依赖关系,就不会出现重排序。
问题:重排序不会对多线程依赖或前后代码依赖,是靠 内存屏障 来实现的。内存屏障:一条 CPU 指令,确保某些特定的操作的顺序(主要是有依赖关系的写),可以理解为强制刷新处理器的写缓存(多线程中每个线程的多线程)。注明:单 CPU 不需要内存屏障。
实现:内存屏障的一些体现例如 volatile 字段、synchronized 字段、 final 字段以及锁 等。
volatile:JVM 提供的最轻量级的同步机制,它的工作内存写操作,会无效其他处理器所持有、指向的同一地址的缓存行,所以其他处理器可以立即见到 volatile 修饰的字段的最新值。(多读少写:频繁写操作会一直强制刷新缓存,影响性能)
synchronized:重量级锁,通过加锁、解锁,操作具有原子性,使得线程安全。但是不会强制刷新主内存。
扩展知识
原子性、可见性(例如 volatile 能保证新值立即刷新到主内存,所以该线程的数据对其他线程可见)、有序性(本线程内观察,所有线程可见。一个线程观察另一个线程,所有操作都是无序 --- 指令重排序和主内存和工作内存同步延迟 两个原因,导致无序,所以 volatile 禁止指令重排序,变成了有序)
2、垃圾回收
起源:Java 程序在运行过程中,时时刻刻在创建对象,如果不对已经不在使用的对象进行回收,那么很快就会出现 OOM 情况。
解决:JVM 定义了垃圾回收技术来实现对不在使用的对象进行回收。
技术实现:
1)、确定对象的死亡:计数法(仅了解)、可达性分析 --- 从一系列 GC Roots 对象作为起点,搜索能被该 GC Roots 引用到的对象。没有引用到的对象,就可以标记死亡 。多线程情况下可达性分析可能存在漏报和误报。(GC Roots :Java 方法栈帧中的局部变量、已加载的静态变量、JNI handles、已启动未停止的 Java 线程。)
2)、回收时刻:在安全点的时候进行垃圾回收,间接的减少垃圾回收的暂停时间。垃圾回收需要开启一个线程来回收,最开始的时候是暂停其他线程,仅放垃圾回收的线程运行。(安全点:JNI 本地代码等,对程序的对象之类无影响的线程操作)
3)、算法:标记清除算法 --- 效率低和产生大量不连续的内存碎片、复制算法 --- 代价太高昂,只能利用到1/2的内存、标记整理算法。
JVM 的垃圾回收的堆划分:新生代、老年代。
新生代:Eden 区和两个一样大小的 Survivor 区。 Eden 区约到,浪费的堆空间越小。当 Eden 区域的内存耗尽,就出发一次 Minor GC,收集新生代的垃圾。存活下来的对象,就送到 Survivor 区。
老年代:新生代的对象经过15次的赋值或者大对象等,就会把对象放入老年代。
Java 9 以后的垃圾回收器:G1(横跨新生代和老年代的垃圾回收器)。
扩展知识
每个线程可以向 JVM 连续申请一段连续的内存,用来放该线程的多个对象。该操作需要加锁,并且维护两个指针,一个指针指向空余内存的起始位置,一个指针指向该段内存的末尾。接下来的 new 指令,直接通过指针加法来实现,把指向空余内存位置的指针加所请求的字节数。当该段线程使用完后,就再申请一段内存。
3、双亲委派机制
起源:为了保证程序的安全性,保证不能因为人会的 Java 文件注入,加载出来多个相同的 class 文件,从而使程序发生错误。(如果没有双亲委派机制,子类加载器自己就加载了一个API类,并且父类也加载了系统API类,在引用的时候,就会不知道引用哪个API类而报错。)
解决:双亲委派机制。
实现:顶层为启动类加载器,然后下一级为扩展类加载器,再下一级为应用程序加载器,再下一级为自定义类加载器。除了顶层的启动类加载器,其余的类加载器都有自己的父类加载器。不是以父子关系存在,而是以组合关系来复用父加载器的代码。
当一个类加载器收到类加载请求(自己未加载过),将求情往上委派给父类加载器,一直往上,到顶层启动类加载器。 当父类加载器无法完成这个加载请求,子加载器才会去加载。
4、对象逃逸
起源:方法内创建的对象,作为返回参数返回出去后,被其他方法引用。或者对象作为参数,传入下级方法等操作。避免了该对象被标记为死亡的(方法结束,对该对象的引用就断开,就可以视该对象死亡,可用垃圾回收器回收)。这就是对象逃逸。
书本语言:新建对象放入堆中,除了创建对象的线程,其他线程也可获得该对象的引用。
优化:(前提非逃逸的对象)
锁消除:即时编译器能证明该对象不逃逸,说明其他线程对本线程的对象无引用,那么就不需要对该对象进行加锁、解锁,这就是锁消除。
栈上分配:逃逸分析证明对象不逃逸, JVM 就可以将对象分配到栈上,new 结束,栈弹出对象就可以。(无需借助垃圾回收器回收该对象,因为弹栈了)
标量替换:将原来对对象的字段的访问,替换成一个个局部变量的访问(因为对象只在本线程中,所以字段的值也只不会被其他线程可见)。这些字段没有分配实际内存,可以和栈上分配一样,或者直接存放在寄存器中,不需要内存空间。
5、类的加载到卸载流程
加载 --- 验证 --- 准备 --- 解析 ---初始化 --- 使用 --- 卸载
加载:将 Java 文件用类加载器加载为 class 文件。
验证:主要验证魔数、常量池等,验证 class 文件是否符合 JVM 规范。
准备:将类变量分配内存,设置类变量初始值。例如定义一个类变 int i = 2,则设置初始值 i=0。
解析:将常量池的符号引用,替换为直接引用(指向目标的指针或者是一个能直接定位到目标的句柄)。
初始化:实例变量初始换、实例代码块初始化、构造函数初始化。
6、JVM 实现反射
含义:允许正在运行的 Java 程序观测、甚至修改程序的动态行为。IOC 就是依赖于反射机制。(但是反射机制比较慢,性能开销大 --- 其实只是相对的 --- 原因:变长参数方法导致的 Object 数组、基本类型的自动拆装箱、方法内联。)
方式:Class.forName、getClass()、.class。
实现:委派实现、本地实现 --- 每个 Method 实例的第一次反射调用都会生成一个委派实现,所委派的具体实现便是一个本地实现
委派实现:之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换(某个反射调用的次数在15次之下,采用本地实现。达到16次,便开始动态实现,即动态生成字节码)。
扩展知识
Class.getMethod 会遍历该类的公有方法,Class.forName 会调用本地方法。所以操作都 很费时。其实实践中我们会在应用中缓存 Class.forName 和 Class.getMethod 的结果。
调优方法:方法内联、逃逸分析、不自动拆装箱。
方法内联:在编译过程中遇到方法调用时,将目标方法的方法纳入编译范围之中,并取代原方法调用的优化手段。(类似株连)
7、JVM 内存溢出
堆溢出:堆要不断的创建对象,如果避免了垃圾回收来清除这些对象,就会产生JVM内存溢出。一般手段是通过内存映像分析工具对Dump出来的堆转储快照进行分析,分清楚到底是内存泄露还是内存溢出。
虚拟机栈和本地方法栈溢出:线程请求的栈深度大于虚拟机所允许的最大深度。或者虚拟机在扩展栈时无法申请到足够的内存空间。
方法区和运行时常量池溢出:一个类要被垃圾回收器回收,判断条件是苛刻的。
本机直接内存溢出。
当出现内存溢出时,就得定位是哪里溢出,并且做出相关优化。
JVM优化:调整一些 JVM 例如新生代、老年代等内存区域的大小(有钱能做到扩展服务的事情,那就增加服务器吧!)。另外就是代码的优化,尽量少创建对象,少创建大对象等。
JVM 优化参考链接:https://pengjiaheng.iteye.com/blog/552456
8、Java 对象的内存布局
新建对象:Object.clone 方法、反序列化、Unsafe.allocateInstance 方法和 new 语句、反射机制(通过调用构造器来初始化实例变量)。
分配内存:通过 new 出来的对象,内存涵盖了所有父类中的实例变量,就算父类的私有实例变量,子类无法访问,但是子类的实例还是会为父类的实例变量分配内存。
压缩指针:
Java 虚拟机中每个 Java 对象都有一个对象头,由标记字段和类型指针(类型指针指向该对象的类)所构成。
64位的虚拟机中,对象头的标记字段占64位,类型指针占64位。通过压缩指针,将堆中原本的64为的 Java 对象指针压缩为32位。这样一来,对象头的类型指针也会被压缩为32位。标记字段还是64位。这样对象头的大小就从16字节变为12字节。
原理:内存对齐 --- 每个对象的地址对齐到 8 的倍数。但是如果一个对象用不到8N个字节,空白的那部分空间就浪费了。还有个原因是让字段只出现在同一 CPU 的缓存行中,如果字段不对齐,可能出现跨缓存行的字段。虽然浪费了一定的空间,但是可以减少内存行的读取次数,总的来说对于 JVM 的性能得到了一定的提高。
扩展知识
为什么引入基本类型:Integer 类为例子,仅有一个 int 类型的私有字段,占4个字节。但是类型指针就占64位(8字节),每一个 Integer 对象的额外内存开销至少是200%。