JVM

深入理解Java虚拟机 JVM高级特性与最佳实践阅读笔记

2020-06-29  本文已影响0人  kdlllll

本笔记记录了阅读本书觉得重要的知识点,有些过于繁琐的没有记录

2. java内存区域与内存溢出异常

2.2 运行时数据区域

JVM运行时数据区

线程私有:JVM虚拟机栈,本地方法栈,程序计数器
线程共享:方法区,堆

2.2.1 程序计数器

时一块较小的内存空间,可以看作时当前线程所执行的字节码的行号指示器。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个程序计数器。 如果正在执行一个java方法,计数器的值就是字节码指令的地址,如果正在执行native方法,值为空,此内存区域是规范种唯一没有规定任何OutOfMemoryError情况的区域。

2.2.2 Java虚拟机栈

它的生命周期与线程相同,描述的是Java方法执行的内存模型:每个方法执行的时候都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。 每一个方法的调用直至执行完成的过程,就对应整一个战阵在虚拟机栈种入栈到出栈的过程。局部变量表存放着编译器可知的各种基本数据类型和对象以用,64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用一个。局部变量表所需内存空间在编译期间完成分配。JVM规范种对这个区域规定了两种异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError,如果虚拟机栈支持动态扩展(大部分虚拟机支持),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

2.2.3 本地方法栈

本地方法栈位虚拟机使用到的native方法服务,也会抛出和虚拟机栈同样的异常。

2.2.4 Java堆

存放对象实例和数组。Java堆时垃圾收集器管理的主要区域。可以细分为:新生代和老年代,再细分有Eden空间,From Suvivor空间,To suvivor空间。

2.2.5 方法区

用于存储虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。在Hotspot虚拟机种,将垃圾收集应用到了方法区,方法区因此也被称为永久代。

2.2.6 运行时常量池

运行时常量池时方法区的一部分,Class文件种除了有类的版本,字段,方法,接口等描述信息歪歪,还有一项信息时常量池,用于存放编译器生成的各种字面量和符号引用(静态常量池),这部分内容将在类加载后进入方法去的运行时常量池,常量池并非预置如Classs文件种常量池的内容才能进入运行时常量池,运行期间也可能将新的常量放入池种,如String类的intern()方法。

2.2.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是JVM规范种定义的内存区域,但这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。如JDK1.4一i纳入的NIO(New Input/Output),通过通道与缓冲区的方式,使用Native函数库直接分配堆外内存,避免了在Java堆和Native堆种来回复制数据。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建
  1. 当虚拟机遇到一条new指令时,受限检查这个指令的参数是否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载,解析和初始化过。如果没有,执行类加载过程。
  2. 类加载完成后,将为新生对象分配内存,假设堆中的内存绝对规整,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所有分配内存就仅仅时把哪个指针向 空闲那边挪动一段与对象大小相等的一段举例,这种方式成为“指针碰撞 ”。如果内存不规整,虚拟机必须维护一个列表记录哪些内存块时可用的,在分配的时候从列表中找到一块足够打的空间划分给对象实例,并更新列表上的记录,这种方式成为“空闲列表”。使用哪种方式看不同的垃圾收集器是否带有压缩整理功能,还有一个原因是多线程环境下的对象创建存在并发问题,虚拟机采用CAS配上失败重试的方式保证更新操作的原子性,另一种是把内存分配的动作按照西安城划分在不同欧冠的空间中运行,即每个西安城在JAVA堆中预先分配一小块内存,成为本地西安城缓冲。
  3. 内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)这样可以保证对象的实例字段在Java代码中可以不赋初始值就直接使用
  4. 接下来迅即堆对象进行必要的设置,这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等,这些信息在对象头中.
  5. 在上面工作完成后,从虚拟机角度看,新对象已经产生,操你个JAVA程序看,对象创建才刚刚开始,<init>方法还没有执行,所有字段都还为零,一般来说(字节码中是否跟随invokespecial指令),执行new指令之后会执行<init>方法,把对象按照程序员的意愿进行初始化.
2.3.2 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块:

  1. 对象头包括两部分信息,第一部分用于存储对象自身运行时数据,hashcode,GC分带年龄,所状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,这部分数据长度在32位和64位虚拟机中分别为32bit和64bit,官方称之为"mark word",对象头另一部分时类型指针,只想它的类元数据指针.


    markword在不同锁状态下的数据存储
  2. 实例数据时对象真正存储的有效信息
  3. 对齐填充并不是必然存在的,仅仅起着占位符的作用,HotSpot虚拟机凑够8字节的整数倍.

为什么需要内存对齐以及对齐规则的简单分析

2.3.3 对象的访问定位
  1. 句柄访问,堆中将会划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息


    句柄访问
  2. 直接指针,reference中存储的直接就是对象地址


    直接访问

使用句柄来访问最大的好处是句柄地址稳定,如果对象被移动,只需改变句柄中的实例数据指针,reference无需修改
直接内存速度更快,但不利于对象移动.HotSpot中使用的是第二种方式.

3. 垃圾收集器与内存分配策略

3.2 对象已死吗

3.2.1 引用计数算法

给对象添加一个引用计数器,当一个地方引用它时,计数器值加1,引用失效时,计数器值减1,计数为0时对象不再被引用,效率高,但是无法解决对象间的仙湖循环引用问题.

3.2.2 可达性分析算法

JAVA中是通过该算法判定对象是否存活.通过一系列被成为"GC ROOTS"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTs没有任何引用链则证明对象是不可用的.


可达性分析算法

可以作为GC ROOTS的对象包括:

  • 虚拟机栈
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
3.2.3 再谈引用

在JDK1.2以前,引用的定位为reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就代表着引用. 1.2之后进行了扩充为强度逐渐减弱的4种:

  1. 强引用:类似Object obj =new Object()这类,只要引用还在,垃圾收集器就不会回收.
  2. 软引用:还有用但并非必须的对象,系统发生内存移除前,将会把这些对象进行回收,如果还是没有足够的内存,才会抛出内存溢出异常.JDK1.2后提过了SoftReference类来实现软引用.
  3. 弱引用:只能生存道下一次垃圾收集发生前,无论当前内存是否足够,都会回收,WeakReference类来实现弱引用
  4. 虚引用:一个对象是否有虚引用完全不会对其生存时间构成影响,无法通过虚引用来取得一个对象实例,为对象关联虚引用唯一目的是这个对象被回收时收到一个系统通知,PhantomReference类来实现虚引用

你不可不知的Java引用类型之——虚引用

3.2.4 生存还是死亡

当对象不可达时,并非"非死不可",收集器后判断该对象是否有必要执行finalize方法,如果对象覆盖了该方法且虚拟机尚未执行该方法,会被放在F-queue队列中,并在杀后由虚拟机建立的低优先级的Finalizer线程执行,虚拟机不保证该方法一定执行结束,可以在该方法种使该对象重新关联强引用达到自救的目的.

3.2.4 回收方法区

没有任何reference引用常量池种的对象,就会被废弃,判定常量是否废弃比较简单,判定一个类是否无用需要3个条件:

  1. 该类所有实例已经被回收
  2. 加载该类的ClassLoader被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用

3.3 垃圾收集算法

3.3.1 标记-清除算法

最基础的算法,后续的收集算法都是基于这种思路并对其不足进行改进而得到.
算法分为"标记"和"清除"两个阶段,受限标记处需要回收的对象,完成后统一回收被标记的对象,存在效率问题.


标记清除
3.3.2 复制算法

将内存划分为大小相等的两块,每次只使用其中的一块,当这一块用完了,就将存活的对象复制到另一块上面,然后再把已使用的内存空间一次全清掉.该算法只使用了其中一般的内存,代价太高.


复制算法

大多数的商业虚拟机都采用这种算法回收新生代,将内存分为一块较打的Eden空间和两块较小的survivor空间,每次使用Eden和其中的一块survivor,当回收时将存活对象复制到另一个survivor空间,然后清理掉Eden和刚使用过的survivor空间.HotSpot虚拟机默认Eden和Survivor大小比例时8:1,如果survivor空间不够会依赖其他内存(老年代)进行复制担保.

3.3.3 标记-整理算法

标记后不是直接回收可回收对象,而是所有存活对象向一端移动,然后清理掉端边界外的内存.


标记-整理
3.3.4 分代收集

根据对象的存货周期不同将内存划分为几块.新生代种,每次收集时发现有大批量对象死去,只有少量存活,就选用复制算法,老年代种因为对象存活率高,采用"标记-清理"或"标记-整理"算法.

3.4 HotSpot的算法实现

3.4.1 枚举根节点

可达性分析种可作为GC ROOTS的节点主要在全局性的引用(常量,静态属性)与执行上下文(栈帧种的本地变量表)种,如果诸葛检查这里面的引用给,必然会小号很多时间.
可达性分析还体现在GC停顿上,这个工作必须能确保能在一致性的快照中进行,不可以出现在分析过程种对象引用关系还在不断变化的情况,期间会停顿掉所有线程(Stop the world)。由于目前主流JVM使用准确式GC(准确式GC与保守式GC)所以当执行系统停顿下来后,并不需要一个不漏的检查所有执行上下文和全局的引用位置,虚拟机应当有办法直接得知哪些地方存放着对象引用。在HotSpot的视线中使用一组成为OopMap的数据结构来达到这个目的,在类加载完成的时候,把对象内什么偏移量上使什么类型的数据计算出来,在JIT编译(JIT编译技术)过程中也会在特定位置记录下栈和寄存器中哪些位置使引用,这样GC在扫描时就可以直接得知这些信息了。

3.4.2 安全点

在OopMap的协助下,HotSpot可以快速且准确的完成GC ROOTS的枚举,
但如果为每一条指令生成对应的OopMap将会需要大量的额外空间,HotSpot只是在“安全点”(SafePoint)记录了这些信息,即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点才暂停,一般为方法调用,循环跳转,异常跳转处。GC发生时让所有线程跑到最近的安全点上停顿下来,有两种方案:

  1. 抢先式中断:不需要线程执行代码主动配合,在GC时,受限把所有线程全部中断,如果发现有线程中断的地方不在安全点,就恢复线程,让它跑到安全点上,几乎没有虚拟机采用这种中断方式
  2. 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅见到那设置一个标志,哥哥线程执行时主动区轮询这个标志,发现中断标志为真就自己中断挂起。
3.4.3 安全区域

安全点解决了正在执行中的线程的中断需求,如果线程不在执行如sleep状态或Blocked状态,这种情况通过“安全区域”(safe region)解决。安全区域指一段代码片段中,引用关系不会发生变化。线程执行到Safe Region代码时,受限表示自己已经进入,GC时就不用管这个线程了,线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举,如果完成,线程继续执行,否则等待知道收到可以安全离开的信号为止。

3.5 垃圾收集器

HotSpot虚拟机垃圾收集器
3.5.1 Serial收集器

最基本的新生代收集器,使用复制算法,单线程。老而无用,弃之可惜


image.png
3.5.2 ParNew收集器

Serial的多线程版本


image.png
3.5.3 Parallel Scanvenge 收集器

新生代复制算法,关注吞吐量即用户时间/(用户时间+GC时间)。

3.5.4 Serial Old收集器

老年代单线程,标记-整理算法


image.png
3.5.5 Parallel Old收集器

是Parallel Scanvenge老年代版本“标记-整理”算法


image.png
3.5.6 CMS收集器

Concurrent Mak Sweep以获取最短回收停顿时间为目标的收集器,基于标记-清理算法。

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除


    image.png
3.5.7 G1收集器

面向服务端应用的垃圾收集器

  1. 并行与并发
  2. 分代收集
  3. 空间整合
  4. 可预测的停顿
    G1之前其他收集器进行收集的范围是整个新生代或者老年代,而G1不再是这样,它将这个歌JAVA堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region的集合,G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),维护一个优先列表,优先回收价值大的Region。
  5. 初始标记
  6. 并发标记
  7. 最终标记
  8. 筛选回收


    image.png

    关于GC的JVM参数


    image.png
    image.png

JVM默认GC收集器
STW

3.6 内存分配与回收策略

3.6.1 对象优先再Eden分配

大多数情况下,对象再新生代Eden区中分配,当Eden区没有足够空间进行分配时,将发起一次Minor GC

3.6.2 大对象直接进入老年代

需要大量连续内存空间的JAVA对象,如很长的字符串以及数组

3.6.3 长期存活的对象将进入老年代

如果对象经过一次Minor GC,年龄就增加1岁,达到一定程度(默认15),就会被晋升到老年代中

3.6.4 动态对象年龄判定

为了更好适应不同程序内存状况,如果再Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

3.6.5 空间分配担保

在发生Minor GC之前,虚拟机检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如哦个成立,就是安全的,如果不成立,就会查看 HandlePromotionFailure设置值是否允许担保失败,若允许,继续检查老年代最大可用连续空间是否大于历次禁声到老年代对象的平均大小,如果大于,尝试一次Minor GC,尽管这次有风险;若小于改为进行一次Full GC。如果担保失败,就触发Full GC

新生代老年代大小

4. 虚拟机性能监控与故障处理工具

4.2 JDK的命令行工具

4.2.1 jps:虚拟机进程状况工具

可以列除正在运行的虚拟机进程,并显示虚拟机执行主类。


image.png
4.2.2 jstat:虚拟机统计信息监视工具

显示虚拟机进程类装载,内存,垃圾收集,JIT编译等运行数据


image.png
4.2.3 jinfo:Javap配置信息工具

实时的查看和调整虚拟机各项参数 jinfo -flag可以查看JVM隐式参数


查看指定参数
4.2.4 jmap:Java内存映像工具

用于生成堆转存储快照(heap dump),也可以使用参数-XX:HeapDumpOnOutOfMemoryError使虚拟机OOM异常后自动生成dump文件。
jmap不仅仅使为了获取dump文件,还可以查询finalize执行队列,Java堆和永久代的详细信息,空间使用率,当前用的使那种收集器等。


image.png
4.2.5 jat:虚拟机堆转存储快照分析工具

来分析jmap生成的dump文件,内置HTTP服务器,生成结果可以在浏览器中查看。

4.2.6 jstack:Java堆栈跟踪工具

用于生成虚拟机当前时刻的线程快照。用于定位线程长时间停顿的原因,如线程间死锁,死循环,请求外部资源导致的长时间等待。

image.png
jdk1.5中,java.lang.Thread类新增了getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象,可以完成jstack大部分功能。

4.3 JDK的可视化工具

4.3.1 Jconsole:Java监视与管理控制台
jconsole
4.3.1 JVisualVM:多合一故障处理工具

Btrace:动态调试代码:
调试代码:

 public  void shit() {
        new Thread(()->{
            Random random = new Random();
            while (true) {
                System.out.println("和:"+add(random.nextInt(200), random.nextInt(200)));
                try {
                    TimeUnit.SECONDS.sleep(1L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    public int add(int a, int b) {
        return a+b;
    }

Btrace代码:

/* BTrace Script Template */
import com.sun.btrace.annotations.*;
import static com.sun.btrace.BTraceUtils.*;

@BTrace
public class TracingScript {
    /* put your code here */
    @OnMethod(clazz="com.venustech.ids.cs.CsNeoApplication", method="add", location=@Location(Kind.RETURN))
    public static void func(@Self com.venustech.ids.cs.CsNeoApplication instance,int a,int b,@Return int result){
        println(strcat("参数a:",str(a)));
        println(strcat("参数b:",str(b)));
    }       
}

程序输出:


image.png

Btrace输出:


image.png

5.调优案例分析与实战

6. 类文件结构

6.3 Class类文件的结构

Class文件时一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在Class文件中,中间没有添加任何分隔符


image.png
6.3.1 魔数与Class文件的版本

每个Class文件头4个字节成为魔数,唯一作用是确定这个文件是否为一个能被虚拟机接收的Class文件,值为0xCAFEBABE(咖啡宝贝)。第5,5字节是次版本号,7,8字节是主版本号,JDK1.1之后每个大版本发布主版本号就向上加1,包版本的JDK向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。

6.3.2 常量池

紧接着主次版本号之后的是常量池入口,可以理解为Class文件的资源仓库,他是Class文件结构中与其他项目关联最多的数据类型。
常量池中主要存放两大类常量:字面量和符号引用。字面量如文本字符串,声明为final的常量值等,而符号引用则属于编译原理方面的概念:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符
    常量池中的每一项常量都是一个表


    image.png

6.4 字节码指令简介

虚拟机指令由一个字节长度的,代表着某种特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需参数(操作数)构成。JAVA虚拟机采用面向操作数栈而不是寄存器架构,所以大多数指令都不包含操作数,只有一个操作码。

6.4.1 字节码与数据类型

指令支持的数据类型


image.png
image.png

大部分的指令都没有支持整数类型byte,char,short,甚至没有任何指令支持boolean类型,所以大多数对于boolean,byte,short和char类型数据的操作,实际上都是使用响应的intl欸行作为运算类型

6.4.2 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输(栈帧、局部变量表、操作数栈从i++, ++i理解局部变量表和操作数栈

  1. 将一个局部变量加载到操作数栈:load
  2. 将一个数值从操作数栈存储到局部变量表:store
  3. 将一个常量加载到操作数栈:push,dc,const*
  4. 扩充局部变量表的访问索引指令:wide。
6.4.3 运算指令

运算或算数指令用于对两个操作数栈上的值进行某种特定运算,并把结果冲洗存入到栈顶。所有算术指令:


image.png
6.4.4 类型转换指令

将两种不同数值类型进行相互转换,一般用于实现用户代码中的显示类型转换操作。JVM直接支持小范围类型向大范围类型的安全转换:

  1. int 到long,flot,double
  2. long类型到float,double
  3. float到double
    处理窄化类型转换,必须显示的使用转换指令来完成。包括i2b,i2c,i2s,l2i,f2i,f2l,d2i,d2l,d2f。在将Int或long窄化处理时,仅仅时间的丢掉除最低位N个字节以为的内容给,将一个浮点值窄化时,将遵循:
  4. 如果浮点值时NaN,将转为Int或long的0
  5. 如果浮点值不是无穷大的话,获得整数值v,如果v在int或long的表示范围内,那结果将是v。
  6. 否则根据v的符号,转为所能表示最大或最小整数。
6.4.5 对象创建与访问指令

虽然类实例和数组都是对象,但JVM对它们的创建于操作使用了不同字节码指令。


image.png
6.4.6 操作数栈管理指令
image.png
6.4.7 控制转移指令
image.png
6.4.8 方法调用和返回指令
image.png
6.4.9 异常处理指令

显示抛出异常都是由athrow指令来实现,运行时异常会在其他指令检测到异常状况时自动抛出。

6.4.10 同步指令

方法级的同步是隐式的,虚拟机从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否位同步方法,方法调用时,指令将会检查该标志是否被设置了,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,方法完成时释放管程,如果方法抛出异常,那么管程将在异常跑到同步方法之外时自动释放。
同步指令由monitorentermonitorexit两条指令支持。

7. 虚拟机类加载机制

类加载到虚拟机内存中,到卸载出内存,整个声明周期包括:加载、验证、准备、解析、初始化、使用和卸载,验证、准本、解析统称为连接


类生命周期

类的加载过程必须按照这种顺序按部就班的开始,而接卸阶段不同,可能再某些情况下再初始化阶段之后在开始。按部就班不是指按顺序“进行”或“完成”,通常会再一个阶段执行的过程中调用、激活另外一个阶段。
对于初始化阶段,JVM规范严格规定了5种情况必须立即对类进行初始化。

  1. 遇到new,putstatic,invokestatic(被final修饰的静态变量,再编译器把结果放入常量池的字段不会触发初始化);
  2. 使用反射调用;
  3. 当初始化一个类,发现其父类没有初始化,先触发父类的初始化;
  4. 虚拟机启动初始化主类;
  5. JDK1.7动态语言支持,如果一个jaa.lang.invoke.MethodHandler实例最后解析结果REF_getStati,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要触发其初始化;
    以上行为称为对一个类的主动引用。除此之外,所有引用类的方式都不会触发初始化,被称为被动引用。
    被动引用1
    不会触发SubClass的初始化,对于静态字段的引用,只会触发直接关联的类的初始化。
    被动引用2
    并没有触发SuperClass初始化,但触发了另外一个名为[Lorg.fenixsoft.classloading.SuperClass的初始化,它是由虚拟机自动生成的,直接继承于Object的字类,由指令newarray触发。这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组。数组中应有的属性和方法(用户可直接使用的只有被修饰为public的属性和clone()方法)都实现在这个类里。
    被动引用3
    以上没有触发ConstClass的初始化。把常量放在了名为NotInitialization类的常量池中,NotInitialization的class文件中并没有对ConstClass的符号引用。

7.3 类加载过程

7.3.1 加载
  1. 通过类的全限定名来获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转化为方法区时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问接口。
    对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类与类加载器仍然由很密切的关系,因为数组类的元素类型最终要靠类加载器去创建,一个数组类创建过程遵循以下规则:
  4. 如果数组的组件类型时引用类型,那就递归采用上面定义的加载过程去加载这个组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识。
  5. 如果数组的组件类型不是引用类型,Java虚拟机将会把数组标记为与引导类加载器关联。
  6. 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。
    加载完成后,二进制字节流就按照虚拟机所需的格式存储在方法区中,然后在内存中实例化一个java.lang.Class类的对象,这个对象将作为 程序访问方法区中的这些类型数据的外部接口。
    加载阶段与连接阶段时交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,这两个阶段的开始时间仍然保持着固定的先后顺序。
7.3.2 验证

这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机要求,并且不会危害虚拟机自身的安全。验证大致会完成下面4个阶段:

  1. 文件格式验证
    该验证阶段目的保证输入的字节流能正确地解析并存储于方法区之内,基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储。后面的3个阶段都是基于方法区的存储结构进行,不会再直接操作字节流。


    image.png
  2. 元数据验证
    第二阶段对字节码描述的信息进行语义分析,以保证其 描述的信息符合Java语言规范的要求。

  1. 字节码验证
    通过数据流和控制流分析,确定程序语义是否合法,符合逻辑。第二阶段对元数据信息中的数据类型做完校验之后,该阶段将对类的方法体进行分析校验,确保被校验类的方法在运行时不会做出危害虚拟机安全的事件。


    image.png
  2. 符号引用验证
    最后一个阶段校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段-解析中发生。


    image.png

    符号引用验证时确保解析动作能正常执行。

7.3.3 准备

准备阶段时正式为类变量分配内存并设置类变量初始化值得阶段,这个时候进行分配仅包括类变量(static修饰),而不是包括实例变量,实例变量将在对象实例化时随着对象一起分配在Java堆中。这里的初始值“通常情况下”时数据类型的零值,假设

public static int value = 123;

便改良在准备阶段过后的初始值为0而不是123,因为尚未开始执行任何Java方法,而把value赋值为123的putstatic指令时程序被编译后,存放于类构造器<cinit>()方法之中,所以value被赋值为123的动作将在初始化阶段才会执行。


数据类型0值

上面提到“通常情况”,那相对的”特殊情况“:如果类字段的字段属性表中存在ConstantValue属性,那再准备阶段变量value就会被初始化为ConstantVlue属性所指定的值

public static final int value = 123;

编译时javac会为value生成ConstantValue属性,再准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123.

7.3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型的常量出现。
符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时无歧义地定位到目标即可,符号引用与虚拟机实现地内存布局无关,引用地目标不一定已经加载到内存中。
直接引用:直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

7.3.5 初始化

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序指定的主管计划区初始化类变量和其他资源:初始化阶段是执行类构造器<clinit>()方法的过程 。

  1. <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定在块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。


    非法向前引用变量
  2. <clinit>()方法是与类的构造函数(实例构造函数<init>()方法 )不同,它不需要显示的调用父类构造器,虚拟机会保证在字类的<clinit>方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
  3. 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于字类的变量赋值操作。
  4. <clinit>()方法对于类或接口并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,就不会生成这个方法。
  5. 接口可以定义静态变量,所以会生成<clinit>()方法,执行接口的该方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量使用时,父接口才会初始化。接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  6. 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁,同步,如果多个线程同时区初始化一个类,那么只会由一个线程去执行这个类地<clinit>()方法,其他线程都需要阻塞等待, 那么只会有一个线程去执行这个类地<clinit>()方法,其他线程阻塞等待。


    多个线程初始化类
    只有一个线程在初始化并阻塞

7.4 类加载器

8. 虚拟机字节码执行引擎

8.2 运行时栈帧结构

栈帧时用于支持虚拟机进行方法调用和方法执行的数据机构,它时虚拟机运行时数据区中的虚拟机栈的栈元素。存储了方法的局部变量表,操作数栈,方法返回地址等信息。每一个方法从调用到执行完成的过程,对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经确定了,并且 写入到放发表的Code属性中。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态,对于执行引擎来说,只有栈顶的栈帧 才是有效的,成为当前栈帧,于这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。


image.png
8.2.1 局部变量表

是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量表。
以变量槽(Variable Slot)为最小单位,规范中没有明确规定Slot的空间大小,只是很有导向性的说到每个Slot都应该能存放一个boolean,byte,char,short,int,float,reference或returnAddress类型的数据,这8中数据类型,都可以使用32位或更小的物理内存来存放。
reference类型标识对一个对象实例的引用,虚拟机规范没有说明它的长度,也没有明确指出它的结构,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接找到对象在Java堆中的数据存放的起始地址索引,而是直接或间接查找对象所属数据类型在方法区中的存储类型信息。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
虚拟机通过索引定位的方式使用局部变量表,如果是32位数据类型变量索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。
如果执行的实例方法,那局部变量表第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐式参数。
变量表中的槽可以被复用,变量的作用域通常不是覆盖整个方法,所以离开变量的作用域后,槽可以被其他变量服用。以下代码示例:
创建大对象,手动调用full gc

  1. 变量作用域在整个方法中
public class Test1 {

    public static void main(String[] args) {
        byte[] array = new byte[1024*1024*64];
        System.gc();
    }

}
老年代未回收
  1. 加作用域
public class Test1 {

    public static void main(String[] args) {
        {
            byte[] array = new byte[1024*1024*64];
        }
        System.gc();
    }

}

老年代未回收
  1. 加入其他变量服用Slot
public class Test1 {

    public static void main(String[] args) {
        {
            byte[] array = new byte[1024*1024*64];
        }
        int a = 3;
        System.gc();
    }

}
老年代回收

如果代码有一些耗时很长的操作,而前面又定义了占用了大量内存,实际已不会使用的变量,手动将其设置为null。
局部变量的变量使用必须被赋予初始值。

8.2.2 操作数栈

操作数栈是一个先进后出的栈,同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中,在方法执行的时候,深度不会超过这个属性的最大值。
操作数栈中元素的类型必须于字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再验证这一点。以两个int相加为例,执行时 ,栈顶的两个元素的数据类型必须为int,不能出现一个long和一个float使用iadd命令相加的情况。
大多数虚拟机的实现里会做一些优化处理,令两个栈帧出现一部分重叠,让下面的栈帧的部分操作数栈于上面的栈帧的局部变量表重叠在一起,这样方法调用时就可以公用一部分数据,避免额外的复制传递。


栈帧之间的数据共享

JVM的解释执行引擎成为“基于栈的执行引擎”,“栈”指的就是操作数栈

8.2.3 动态链接

每个 栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了支持方法调用过程中的动态链接。

8.2.4 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法,第一种方式时执行引擎遇到任意一个方法返回的字节码指令,这时候可能回有返回值传递给上层的方法调用者,是否又返回值和返回值的类型根据遇到的何种方法返回指令来决定,这种退出方法的方式成为正常完成出口。
另一种退出方式时,在方法执行过程中遇到了异常,并且异常没有在方法体内得到处理,无论时JVM内部产生的异常还是代码中使用athrow字节码指令产生的异常,只要是在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式成为异常完成出口。一个方法使用工艺厂完成出口的方式退出,是不会给它的上层调用这产生任何返回值的。
无论采用何种方式退出,都需要返回到方法被调用的位置,程序才能继续执行
,方法退出时可能需要在栈帧中保存一些信息,用来帮助恢复他的上层方法的执行状态。方法正常退出时,调用者的PC计数器可以作为返回地址,栈帧中很可能会保存这个计数器值,方法异常退出时,返回地址时通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出实际上就等同于把当前栈帧出栈,一次退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者站着呢的操作数栈中,调整PC计数器的值以指向方法调用质量后面的一条指令等。

8.2.5 附加信息

JVM规范中允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息。实际开发中,一般会把动态链接,方法返回地址与其他附加信息全部归为一类,成为栈帧信息。

8.3 方法调用

方法调用并不等于方法执行,方法调用阶段唯一的额任务时确定被调用方法的版本(即调用哪一个方法),不涉及到方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。

8.3.1 解析

所有方法调用中的的目标方法在Class文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期时不可变的。换句话说调用目标在程序代码写好,编译器进行编译时就必须确定下来,这类方法的调用成为解析。
在Java语言中符合”编译器可知,运行期不可变“这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型关联,后者在外部不可被访问,不会被重写,JVM里面提供了5条方法调用字节码指令:

  1. Invokestatic:调用静态方法。
  2. invokespecial:调用实例构造器<init>方法,私有方法和父类方法。
  3. invokevirtual:调用所有的虚方法。
  4. invokeinterfafce:调用接口方法,会在运行时再确定一个实现此接口的对象。
  5. invokedynamic:先在运行时动态解析出调用点限定符所引用的方法 ,然后再执行该方法,在此之前的4条调用指令,分配逻辑是固化在Java虚拟机内部的,而该指令的分派逻辑是由用户所设定的引导方法决定的。
    只要被invokestatic和invodkespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,由静态方法,私有方法,实例构造器,父类方法。在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以成为非虚方法。Java中的非虚方法除了使用invokestatic,invokespecial调用的方法之外还有一种,就是被final修饰的方法,方法无法被覆盖没有其他版本。
    解析调用一定是个静态 的过程 ,在编译期间就完全确定。
8.3.2 分派

静态分派,如下代码:

image.png
运行结果

其中 Human man = new Man();Human称为变量的静态类型,或者叫外观类型,后面的man称为实际类型。静态类型和实际类型再程序中都可以发生一些变化,静态类型变化仅仅再使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候不知道一个对象的实际类型是什么。如下:

image.png
使用哪个重载版本,完全取决于传入参数的数量和类型,通过静态类型作为判定依据,这种方法称为静态分派,典型应用就是方法重载,静态分派发生在编译阶段,重载版本可能由多个版本,往往只能确定一个最合适的版本。
动态分派:它是多态性的另外一个体现-重写(Override)。
代码
运行结果
虚拟机如何判断调用哪个方法。
字节码
符号引用Human.sayHello()完全一样,但执行的目标方法并不相同,invokevirtual指令多态查找过程大致分为:
  1. 执行操作数栈顶第一个元素执行对象的实际类型,基座C。
  2. 如果类型C中找到常量中的描述符和简单 名称相符的方法,则进行访问权限校验,如果通过则返回方法的直接引用,如果不通过,则返回IllegalAccessError异常。
  3. 否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索和验证。
  4. 如果没有合适的方法,则抛出AbstractMethodError异常。
    单分派与多分派:
    方法的接收者与方法的参数统称为宗量。
8.3.3 动态类型语言支持

动态类型语言
动态类型语言的关键特征是它的类型检查的主题过程是在运行起而不是编译器,如Erlang,Groovy,JavaScript

8.4 基于栈的字节码解释执行器

8.4.1 解释执行
编译过程

Javac编译器完成了程序代码经过词法分析,语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程,这一过程再JVM之外进行,而解释器再虚拟机内部,所以Java程序的编译就是半独立的实现。

8.4.2 基于栈的指令集与基于寄存器的指令集

两数相加:


基于栈的指令集 基于寄存器的指令集

基于栈的指令集优点就是可移植,寄存器由硬件提供,程序直接依赖这些寄存器会收到硬件的约束。

8.4.3 基于栈的解释器执行过程
代码
字节码
虚拟机栈
image.png
image.png
image.png
image.png

10. 早期(编译期)优化

10.1 概述

前端编译器:把java文件转变成class文件的过程如javac,eclipse JDT中的增量式编译器(ECJ)
JIT编译器:运行起编译器把字节码 转变成机器码的过程,如HotSpot VM的C1,C2编译器
AOT编译器:静态提前编译器,把java编译成机器码,如GNU Compiler for the java,excelsior JET

10.2 Javac编译器

由Java语言编写

10.2.1 Javac流程
Javac编译过程
10.2.4 语法分析与字节码生成
  1. 标注检查
  2. 数据及控制流分析
  3. 解语法糖
  4. 字节码生成
    生成实例构造器 <init>()方法了类构造器<clinit>()方法
    字类构造器无需调用父类构造器<clinit>(),虚拟机会保证调用
    将字符串加操作替换为StringBuffer或StringBuilder的append()操作

10.3 Java语法糖的味道

10.3.1 泛型与类擦除
10.3.2 自动装箱,拆箱与遍历循环
原代码
编译再反编译后的代码

举例:

public static void main(String[] args) {

        // 注释为我猜测
        Integer a = 1;
        Integer b = 2;
        Integer c = 3;
        Integer d = 3;
        Integer e = 321;
        Integer f = 321;
        Long g = 3L;

        System.out.println(c==d);   // true
        System.out.println(e==f);   // false
        System.out.println(c==(a+b));   // true
        System.out.println(c.equals(a+b));  // true
        System.out.println(g==(a+b));   // false
        System.out.println(g.equals((a+b)));    // true

    }

结果

编译后再反编译:

public static void main(String[] args) {
        Integer a = 1;
        Integer b = 2;
        Integer c = 3;
        Integer d = 3;
        Integer e = 321;
        Integer f = 321;
        Long g = 3L;
        System.out.println(c == d);
        System.out.println(e == f);
        System.out.println(c == a + b);
        System.out.println(c.equals(a + b));
        System.out.println(g == (long)(a + b));
        //解释如下
        System.out.println(g.equals(a + b));
    }

Long.equals方法

public boolean equals(Object obj) {
        if (obj instanceof Long) {
            return value == ((Long)obj).longValue();
        }
        return false;
    }

类型不同直接false

10.3.3 条件编译

Java语言中的条件编译的实现也是一颗语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除掉。

11. 晚期(运行期)优化

11.1 概述

Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码 认定为“热点代码”,为了 提高热点代码的执行效率 ,在运行时,虚拟机会把这些代码编译成与本地平台相关的机器码。这种编译器称为即时编译器。

11.2 hotspot虚拟机的即时编译器

11.2.1 解释器与编译器

主流的商用虚拟机如HostSpot,J9等都同时包含解释器与编译器,解释器与编译器两者各有优势,当程序需要询速启动和执行的时候,解释器首先发挥作用,省区编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。如程序运行环境内存资源限制较打,可以使用解释执行节约内存,反之可以使用编译执行来提高效率。


image.png

HotSpot虚拟机中内置两个即时编译器,Client Compiler和Server Compiler或简称未C1和C2。主流HotSpot采用解释器与其中一个编译器直接配合的方式工作。HotSpot虚拟机(JDK1.7及之前版本的虚拟机)会根据自身版本与宿主机器的硬件ing能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。
由于即时编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码 ,锁花费的时间 可能更长,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动与运行效率之间达到最佳平衡,HotSpot虚拟机还会主键启用分层 编译的策略,在JDK1.7的Server模式虚拟机中作为默认编译策略被开启,分层编译根据编译器编译,优化的规模与耗时,划分出不同的编译层次,其中包括:

11.2.2 编译对象与触发条件

“热点代码“有两类,即:

回边计数器触发的即时编译

回边计数器没有热度衰减过程,当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法 的时候就会执行标准编译过程。

11.2.3 编译过程

默认设置下,无论是方法调用产生的即时编译请求还是OSR编译请求,在未完成代码编译前,仍然按照解释方式继续执行,编译动作则在后台的编译线程中进行,可以通过参数-XX:-BackgroundCompilation来禁止后台编译,禁止后台编译后,一旦达到JIT编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
在后台执行编译的过程中,编译器做了什么事情呢?C1和C2两个编译器编译过程不一样,对于Client Compiler,是一个简单的三段式编译器,主要关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

11.3 编译优化技术

11.3.2 公共子表达式消除

如果表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式,欸有必要再对它进行计算。
如表达式int d = (c * b) * 12 + a + (a + b * c),如果交给javac不会进行任何优化,生成的字节码如下

image.png
当这些字节码进入即时编译器后,将会做如下优化,编译器检测到cb 和 bc 是一样的表达式,计算期间b与c的值不变,因此可以视为
int d = E * 12 + a + (a + E)
11.3.3 数组边界检查消除

Java是一门动态安全的语言,对数组的读写访问不像C/C++那样本质上是裸指针操作,Java会对数组元素访问进行上下文范围检查。
无论如何,数组边界检查必须要做,但是不是每次运行期间都去检查,对于数组下标已知,明确下来的值,编译器判断下标没有越界,执行的时候就无需判断了。

11.3.4 方法内联

方法内联把目标方法复制到调用方法之中,避免真实的方法调用,只有使用invokespecial指令调用的私有方法,实例构造器,父类方法以及使用invokestatic指令调用的静态方法才是编译器进行解析的,其他方法调用都需要再运行期间进行多态选择,编译器做内联的时候根本无法确定应该使用哪个方法版本,为了解决此问题,引入了“类型继承关系分析”(Class Hierachy Analysis, CHA)技术,用于确定目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。
编译器再进行内联时,如果时非虚方法,直接内联则可,如果是虚方法,则会向CHA查询此方法再当前程序下是否有多个目标版本,如果只有一个版本,那也可以进行内联,不过这种内联属于仅仅优化,需要预留“逃生门”,如果程序后续执行中,虚拟机一直没有加载到会令这个方法的接收者继承关系发生变化的类,就需要抛弃已经编译的代码,退回到解释执行,或者进行重新编译。
如果CHA查询出的结果有多个版本,则会进行最后一次努力,使用内联缓存来完成方法内联,这是建立再目标方法正常入口之前的缓存:在未发射管调用前,内存状态为空,当第一次调用发生后,缓存记录下方法接收者版本信息,并且每次进行调用时都比较接收者版本,如果发生了方法接收者不一致的情况,就说明程序使用了虚方法的多态性,会取消内联,查找虚方法表进行方法分配。

11.3.5 逃逸分析

不是直接优化代码,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,可能被外部方法所引用,如调用参数传递到其他方法,成为方法逃逸,甚至还有可能被外部线程访问到,如赋值给类变量,成为线程逃逸。
如果能证明一个方法不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化:
栈上分配:如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配将会是一个不错的主意,对向所占用的空间可以随栈栈帧出栈而销毁,在一般的应用中,不会逃逸的局部对象占比很大,如果使用栈上分配,垃圾收集系统的压力会小很多。
同步消除:线程同步算是一个相对耗时的过程,如果逃逸分析能确定变量不会逃逸,无法被其他线程访问,那这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也可消除。
标量替换:标量指一个数据无法再分解成更小的数据了,如int,long,reference等,相对的,可以继续分解的数据成为聚合量,如Java对象。如果把一个Java对象查三,根据程序的访问情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析整明一个对象不i会被外部访问,并且这个对象可以被拆散的话,程序真正执行的时候可能不创建这个对象,而改为直接创建它的若尬歌被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量再栈上分配(栈上存储的数据,有很大的概率被虚拟机分配至物理机器的高速寄存器),还可以为后续进一步优化手段创建条件。

12. java内存模型与线程

12.2 硬件的效率与一致性

处理器、告诉缓存、主内存

除了增加告诉缓存之外,为了使得处理器内部运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果使一致的。Java虚拟机的即时编译器也有类似的指令重排序优化。

12.3 Java内存模型

12.3.1 主内存与工作内存

Java内存模型的还要目标使定义程序中各个变量的访问规则,及在 虚拟机中将变量存储到内存和从内存中取出变量 这样的底层细节。
Java内存模型规定了所有变量都存储在主内存(Main Memory 类比物理硬件的主内存),每条线程还有自己的工作内存(Working Memory,类比处理器的高速缓存)


java内存模型

这里的主内存,工作内存 与之前的Java内存区域中的堆,栈,方法区不是同一个层次的内存划分,基本上是没有关系的,如果一定要勉强对应起来,那从变量,主内存,工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存对应于虚拟机栈中的部分区域,从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储与寄存器和高速缓存中,因为程序运行时访问读写的是工作内存。

12.3.2 内存交互操作
  1. lock:作用于主内存的变量 ,它把一个变量标识为一条线程独占的状态。
  2. unlock:作用于主内存的变量 ,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read:作用于主内存变狼,把一个变量的值从主内存传输到工作内存。
  4. load:作用于工作内存的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中。
  5. use:作用于工作内存的变量 ,把工作内存的变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时将会执行这个操作。
  6. assign:作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store:作用于工作内存的变量,他把一个工作内存中一个变量 的值传送到主内存中,一边以后的write操作使用。
  8. write:作用域主内存的变量,把store操作从主内存中得到的变量的值放入主内存的变量中。
    Java内存模型规定 在执行上述8中基本操作时必须满足如下规则:


    image.png
12.3.3 对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。
volatile的第一条语义:保证变量对所有线程的可见性,当一条线程修改了这个变量的值,新值对于其他线程来说是立马可知的。
有的人说volatile变量不存在一致性问题,其实不然,Java里面的 运算并非原子操作,导致volatile变量运算在并发下一样是不安全的。


多线程操作volatile变量自增,每个线程增1w,20个线程

如果以上并发执行结果正确的话,最后应该输出200000。最后的结果却不是。


字节码
当getstatic指令 把 race的值取到操作栈顶时,volatile关键字保证 了race的值此时是正确的,执行iconst_1,iadd指令的时候,其他线程可能已经把race的值加大了,而在操作数栈定的值就变成了过期的数据,putstatic指令就可能把较小的race值同步回到主内存之中。
以上分析使用字节码指令分析是不严谨的,有的操作即时只有一个字节码指令,也可能会由多个机器码指令组成。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,任然需要通过加锁来保证原子性。
  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。
    volatile第二条语义:禁止指令重排序优化。
    指令重排序伪代码
    如果变量initialized没有使用volatile修饰,由于指令重排序,可能导致线程A中initialized=true提前执行(机器码 即汇编代码被提前执行),这样线程B中使用配置信息就可能出现错误,而volatile则可以避免此类情况发生。volatile通过执行“lock addl”操作,相当于一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前。
    对于volatile的特殊规则:
    image.png
    总的来说工作内存与主内存保持一致,load和use,store和write连续出现。

双重检查锁单例模式是否安全

12.3.4 对于long和double型变量的特殊规则

Java内存模型要求上述8条操作都具有原子性,但是对于64位的数据类型,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,这就是long和double的非原子性协定。

12.3.5 原子性、可见性与有序性

原子性:保证原子性变量操作包括:read,load,assign,use,assigin,store,write。
如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中地synchronized关键字。
可见性:指当一个线程修改了共享变量地值,其他线程能立即得知这个修改。前面提到volatile保证修改变量地新值能立即同步到主内存,每次使用前立即从主内存刷新来保证变量的可见性,除了volatile之外,还有两个关键字能实现可见性,synchronized和final,同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store,write操作)”。而final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”(this逃逸)的引用传递出去,那在其他线程中就能看见final字段的值。
有序性:如果在 本线程内观察,所有的操作都是有序的,如果在另一个线程中观察,所有操作都是无序的。前半句指“线程内表现为串行的语义”,后半句指“指令重排序”现象和“工作内存与主内存同步延迟"现象 .

深入理解Java内存模型(六)——final

12.3.6 先行发生原则

如果操作A先行发生于操作B,操作A产生的影响能被操作B观察到,”影响“包括修改了内存共享变量的值,发送了消息,调用了方法等。虚拟机默认拥有的先行发生规则:


image.png

12.4 Java与线程

Java中的并发指线程并发
线程调度方式有两种:协同式调度和抢占式调度,协同式调度,线程自己决定执行时间,运行完毕通知系统切换到另外一个线程,抢占式将由系统来分配执行时间,线程切换不由线程本身来决定。Java使用的是抢占式。Java一共设置了10个级别的优先级,优先级越高,优先级越高,越容易被系统选择执行。

12.4.3 状态转换

Java定义了5中线程状态
新建:创建后为启动的线程。
运行:包括了操作系统线程状态的 运行 和 就绪状态,正在等待CPU分配执行时间或正在执行。
无限期等待:不会被分配CPU执行时间

13. 线程安全与锁优化

不可变:Java语言中,不可变的对象一定是线程安全的,无论是方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。如果共享数据是一个基本数据类型,只要在定义时使用final关键在修饰它就可以保证他是不可变的。如果共享数据时一个对象,就需要保证对象的行为不会对其状态产生任何影响才行。

线程安全

13.2.1 Java

不可变:不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障。

13.2.2 线程安全的实现方法

互斥同步:在Java中,最基本的互斥同步手段就是synchronized关键字,经过编译后会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,在执行Monitorenter指令时,首先要尝试获取对象的锁,如果成功,当前线程就拥有了对象的锁,把锁的计数器加1,执行monitorexit指令计数器减1。Java的线程是映射到操作系统的原生线程之上的,如果阻塞或唤醒一个线程,都需要操作系统来帮忙完成,就需要从用户态转换到核心态中,因此转换需要耗费很多CPU时间,除了synchronized之外,还可以使用JUC中的重入锁来实现同步。JDK1.6之后加入了很多针对锁的优化处理,synchronized与ReentrantLock的性能基本上完全持平,因此如非特殊,没有必要使用ReentrantLock。
非阻塞同步:基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程争用共享数据,操作成功,如果有争用,产生冲突,就需要补偿措施(不断重试)这种乐观的并发策略的许多实现不需要把线程挂起,因此同步操作称为非阻塞同步。这种操作需要硬件指令集,保证操作和冲突检测具备原子性,这类指令常用的有:

image.png
JDK1.5之后才可以使用CAS操作,在sun.misc.Unsafe类,虚拟机对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器的CAS指令。

13.3 锁优化

13.3.1 自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态完成,这些操作都会对系统并发性能带来很大压力,许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果在有1个以上的处理器的物理机器上有两个线程并行执行,可以让后面请求锁的线程等待以下,但不放弃处理器的执行时间,看看 持有锁的线程是否很快就释放锁。自旋次数的默认值10次。JDK1.6引入了自适应的自旋锁,自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,如果同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很可能再次成功,进而它将允许自旋等待持续相对更长的时间。

13.3.2 锁消除

指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

13.3.3 锁粗化

原则上,同步块的作用范围限制的尽量小,大部分情况下都是正确的,但是如果一系列操作都对同一个对象反复加锁和解锁,即使没有线程竞争,频繁进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机检测到由这样一串零碎的操作都对同一个对象加锁,将会把同步范围扩展到整个操作序列的外部。

13.3.4 轻量级锁

【死磕 Java 并发】—– 深入分析 synchronized 的实现原理

上一篇下一篇

猜你喜欢

热点阅读