基于《深入了解java虚拟机》相关章节进行地重点知识归纳,同时面向企业面试,给出常见面试问题解析及自己的见解,如果有不正确的地方欢迎大佬们指正。
博文按照自己的总结进行归纳,主要针对虚拟机重要章节内容进行模块系统性地介绍,如有新内容需要添加或修改,后期会持续更新,欢迎读者浏览指正。通过这篇博客,读者将对jvm有较深入地理解,能够理解许多java开发中遇到的关于jvm的底层问题,将有助于对java代码执行的整个过程有系统性认识和理解。注意:本篇博客没有提供代码证明,考虑在后期持续贴出相关代码证明。
jre、jdk、jvm的关系:
JVM :英文名称(Java Virtual Machine),就是我们耳熟能详的 Java 虚拟机。它只认识 xxx.class 这种类型的文件,它能够将 class 文件中的字节码指令进行识别并调用操作系统向上的 API 完成动作。所以说,jvm 是 Java 能够跨平台的核心,具体的下文会详细说明。
JRE :英文名称(Java Runtime Environment),我们叫它:Java 运行时环境。它主要包含两个部分,jvm 的标准实现和 Java 的一些基本类库。它相对于 jvm 来说,多出来的是一部分的 Java 类库。
JDK :英文名称(Java Development Kit),Java 开发工具包。jdk 是整个 Java 开发的核心,它集成了 jre 和一些好用的小工具。例如:javac.exe,java.exe,jar.exe 等。
显然,这三者的关系是:一层层的嵌套关系。JDK>JRE>JVM。
java技术体系了解:
广义上讲,Groovy等基于JVM的语言及其相关的程序,都属于Java技术体系的一员;
狭义上讲,SUN官方定义的Java技术体系:
1 Java程序设计语言
2 不同版本的JVM
3 Class文件格式
4 Java API
5 第三方提供的Java API
Sun Classic:
世界上第一款商用 Java 虚拟机;只能使用纯解释器方式来执行 Java 代码,也可以使用外挂JIT编译器,JIT 会完全接管虚拟机的执行系统,但是此时解释器不会再工作了;JDK1.2之前是 Sun JDK 中唯一的虚拟机,JDK1.4 时,完全退出商用虚拟机的历史舞台,与 Exact VM 一起进入了Sun Labs Research VM。Exact VM:
它的执行系统已经具备了现代高性能虚拟机的雏形:如两级即时编译器、编译器与解析器混合工作模式等;使用准确式内存管理:虚拟机可以知道内存中某个位置的数据具体是什么类型。有与 HotSpot 几乎一样的热点探测;在商业应用上只存在了很短暂的时间就被更优秀的 HotSpot VM 所取代。我们平时所说的“高性能 Java 虚拟机”一般是指 HotSpot、JRockit、J9 这类在通用平台上运行的商用虚拟机,但其实 Azul VM 和 BEA Liquid VM 这类特定硬件平台专有的虚拟机才是“高性能”的武器。
Azul VM: 是在 HotSpot 基础上进行大量改进,运行于 Azul Systems 公司的专有硬件 Vega 系统上的 Java 虚拟机,每个 Azul VM 实例都可以管理至少数十个 CPU 和数百 GB 内存的硬件资源,并提供在巨大范文内实现可控的 GC 时间的垃圾收集器、为专有硬件优化的线程调度等优秀特性;Liquid VM: 即是现在的 JRockit VE(Virtual Edition),是 BEA 公司开发的,可以直接运行在自家的 Hypervisor 系统上的 Jrockit VM 的虚拟化版本。它不需要操作系统的支持,或者说它自己本身实现了一个专有操作系统的必要功能,如文件系统、网络支持等。由虚拟机越过通用操作系统直接控制硬件可以获得很多好处,如在线程调度时,不需要再进行核态/用户态的切换等,这样可以最大限度的发挥硬件的能力,提升 Java 程序的执行性能。它们只能称为“虚拟机”,而不能称为“Java 虚拟机”。
Apache Harmony:是一个 Apache 软件基金会旗下以 Apache License 协议开源的实际兼容于 JDK1.5 和 JDK1.6 的 Java 程序运行平台。Dalvik VM: 是 Android 平台的核心组成部分之一。没有遵循 Java 虚拟机规范,不能直接执行 Java 的 Class 文件,使用的是寄存器架构而不是 JVM 中常见的栈架构。它执行的 dex(Dalvik Executable)文件可以通过 Class 文件转化而来。根据JVM规范,JVM 内存共分为虚拟机栈,堆,方法区,程序计数器,本地方法栈五个部分,如下图所示:
程序计数器(线程私有): 是当前线程锁执行字节码的行号治时期,每条线程都有一个独立的程序计数器,这类内存也称为“线程私有”的内存。正在执行java方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果是Natice方法,则为空。
java 虚拟机栈(线程私有):每个方法在执行的时候也会创建一个栈帧,存储了局部变量,操作数,动态链接,方法返回地址。 每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。 通常所说的栈,一般是指在虚拟机栈中的局部变量部分。 局部变量所需内存在编译期间完成分配, 如果线程请求的栈深度大于虚拟机所允许的深度,则StackOverflowError。 如果虚拟机栈可以动态扩展,扩展到无法申请足够的内存,则OutOfMemoryError。
本地方法栈(线程私有): 和虚拟机栈类似,主要为虚拟机使用到的Native方法服务。也会抛出StackOverflowError 和OutOfMemoryError。
Java堆(线程共享): 被所有线程共享的一块内存区域,在虚拟机启动的时候创建,用于存放对象实例。 对可以按照可扩展来实现(通过-Xmx 和-Xms 来控制) 当队中没有内存可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。
老年代:2/3的堆空间 年轻代:1/3的堆空间
eden区:8/10 的年轻代survivor0: 1/10 的年轻代survivor1:1/10的年轻代方法区(线程共享): 被所有方法线程共享的一块内存区域。 用于存储已经被虚拟机加载的类信息,常量,静态变量等。 这个区域的内存回收目标主要针对常量池的回收和堆类型的卸载。
其中对象分为:对象头、实例数据、填充三部分,如下图所示:
对象头
markword 第一部分markword,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。klass 对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.数组长度(只有数组对象有) 如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。目前主流的访问方式有两种,分别是:句柄和直接指针。
优缺点对比:
使用句柄来访问的最大好处就是reference中存储的是最稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。而使用直接指针访问方式的最大好处就是速度更加快,它节省了一次指针定位的时间开销,由于兑现搞定访问在Java中非常频繁,因此这类开销积少成多后也是一种非常可观的执行成本。判断对象是否存活的两种算法:引用计数算法 and 可达性分析算法
引用计数法:给 Java 对象添加一个引用计数器,每当有一个地方引用它时,计数器 +1,引用失效则 -1;当计数器不为 0 时,判断该对象存活;否则判断为死亡(计数器 = 0),但是无法解决 对象间相互循环引用 的问题。可达性分析法:将一系列的 GC Roots 对象作为起点,从这些起点开始向下搜索,当一个对象到 GC Roots 没有任何引用链相连时,则判断该对象不可达。注意:可达性分析 仅仅只是判断对象是否可达,但还不足以判断对象是否存活 / 死亡标记-清除算法
等待被回收的对象会被“标记”,如果在被标记后直接对对象进行清除,会带来另一个新的问题——内存碎片化。如果下次有比较大的对象实例需要在堆上分配较大的内存空间时,可能会出现无法找到足够的连续内存而不得不再次触发垃圾回收。
复制算法(Java堆中新生代的垃圾回收算法)
此GC算法实际上解决了标记-清除算法带来的“内存碎片化”问题。首先还是先标记处待回收内存和不用回收的内存,下一步将不用回收的内存复制到新的内存区域,这样旧的内存区域就可以全部回收,而新的内存区域则是连续的。它的缺点就是会损失掉部分系统内存,因为你总要腾出一部分内存用于复制。新的对象实例被创建的时候通常在Eden空间,发生在Eden空间上的GC称为Minor GC,当在新生代发生一次GC后,会将Eden和其中一个Survivor空间的内存复制到另外一个Survivor中,如果反复几次有对象一直存活,此时内存对象将会被移至老年代。
标记-压缩算法(或称为标记-整理算法,Java堆中老年代的垃圾回收算法)
对于新生代,大部分对象都不会存活,所以在新生代中使用复制算法较为高效,而对于老年代来讲,大部分对象可能会继续存活下去,如果此时还是利用复制算法,效率则会降低。标记-压缩算法首先还是“标记”,标记过后,将不用回收的内存对象压缩到内存一端,此时即可直接清除边界处的内存,这样就能避免复制算法带来的效率问题,同时也能避免内存碎片化的问题。老年代的垃圾回收称为“Major GC”。
ConcurrentMarkSweep 老年代 并发的, 垃圾回收和应用程序同时运行,降低STW的时间(200ms)
算法:三色标记 + Incremental Update 1.初始标记:Stop the world,只标记GC Roots能直接关联到的对象。 2.并发标记:GC Roots Tracing 3.重新标记:Stop the world,修正并发标记期间,因用户程序继续运行导致标记产生变动的那一部分对象的标记记录。 4.并发清除:并发清除可以和用户线程一起运行,所以总体上停顿的时间非常短。 但是CMS收集器有三个显著缺点: 1.对CPU资源敏感。 2.无法处理浮动垃圾。 3.收集结束后会产生大量碎片。
G1 (10ms) 算法:三色标记 + SATB 1.G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大; 2.G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩); 3.G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换; 4.G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。 默认的垃圾回收器的配合工作:1-概述:
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可以能被外部方法所引用,例如作为调用参数传递到其它地方种,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或者可以在其它线程中访问的实例变量,称为线程逃逸。逃逸分析是指分析指针动态范围的方法,通俗的说,如果一个对象的指针被多个方法或者线程引用时,那么我们成这个对象的指针发生了逃逸。2-逃逸的三种状态:
全局逃逸:一个对象的引用掏出了方法或者线程。例如:一个对象的引用赋值给一个类变量,或者这个对象的用引用作为方法的返回这返回给了调用方法。参数级逃逸:方法调用过程中,传递对象给另一个方法没有逃逸:一个可以进行标量替换的对象,可以将这个对象不分配在传统的堆上3-逃逸分析作用:
通过逃逸分析,java Hotspot 编译器能够分析出一个新的对象的引用的使用范围,从而觉得是否需要将这个对象分配到堆上。
程序在上线前的测试或运行中有时会出现一些大大小小的JVM问题,比如cpu load过高、请求延迟、tps降低等,甚至出现内存泄漏(每次垃圾收集使用的时间越来越长,垃圾收集频率越来越高,每次垃圾收集清理掉的垃圾数据越来越少)、内存溢出导致系统崩溃,因此需要对JVM进行调优,使得程序在正常运行的前提下,获得更高的用户体验和运行效率。
这里有几个比较重要的指标:
内存占用:程序正常运行需要的内存大小。延迟:由于垃圾收集而引起的程序停顿时间。吞吐量:用户程序运行时间占用户程序和垃圾收集占用总时间的比值。魔术:
每个Class文件的头4个字节称为魔数(Magic Number)唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件魔数的值为0xCAFEBABE。如果一个文件不是以0xCAFEBABE开头,那它就肯定不是Java class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或jpeg等在文件头中都存有魔数。使用魔术而不是使用扩展名是基于安全性考虑的——扩展名可以随意被改变!!!
Class文件的版本号:
紧接着魔数的4个字节是Class文件版本号,版本号又分为:
次版本号(minor_version): 前2字节用于表示次版本号主版本号(major_version): 后2字节用于表示主版本号。常量池:
紧接着魔数与版本号之后的是常量池入口,常量池简单理解为class文件的资源从库
是Class文件结构中与其它项目关联最多的数据类型是占用Class文件空间最大的数据项目之一是在文件中第一个出现的表类型数据项目constant_pool_count:占2字节,本例为0x0016,转化为十进制为22,即说明常量池中有21个常量(只有常量池的计数是从1开始的,其它集合类型均从0开始),索引值为1~21。第0项常量具有特殊意义,如果某些指向常量池索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可以将索引值置为0来表示constant_pool:表类型数据集合,即常量池中每一项常量都是一个表,共有14种(JDK1.7前只有11种)结构各不相同的表结构数据。这14种表都有一个共同的特点,即均由一个u1类型的标志位开始,可以通过这个标志位来判断这个常量属于哪种常量类型访问标志(2字节)
常量池之后的数据结构是访问标志(access_flags),这个标志主要用于识别一些类或接口层次的访问信息,主要包括:
是否final是否public,否则是private是否是接口是否可用invokespecial字节码指令是否是abstact是否是注解是否是枚举类索引、父类索引和接口索引集合
这三项数据主要用于确定这个类的继承关系。类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引(interface)集合是一组u2类型的数据。(多实现单继承)
类索引(this_class),用于确定这个类的全限定名,占2字节父类索引(super_class),用于确定这个类父类的全限定名(Java语言不允许多重继承,故父类索引只有一个。除了java.lang.Object类之外所有类都有父类,故除了java.lang.Object类之外,所有类该字段值都不为0),占2字节接口索引计数器(interfaces_count),占2字节。如果该类没有实现任何接口,则该计数器值为0,并且后面的接口的索引集合将不占用任何字节,接口索引集合(interfaces),一组u2类型数据的集合。用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果该类本身为接口,则为extends语句)后的接口顺序从左至右排列在接口的索引集合中,this_class、super_class与interfaces按顺序排列在访问标志之后,它们中保存的索引值均指向常量池中一个CONSTANT_Class_info类型的常量,通过这个常量中保存的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串字段表集合
fields_count:字段表计数器,即字段表集合中的字段表数据个数,占2字节。本测试类其值为0x0001,即只有一个字段表数据,也就是测试类中只包含一个变量(不算方法内部变量)fields:字段表集合,一组字段表类型数据的集合。字段表用于描述接口或类中声明的变量,包括类级别(static)和实例级别变量,不包括在方法内部声明的变量方法表集合:
methods_count:方法表计数器,即方法表集合中的方法表数据个数。占2字节,其值为0x0002,即测试类中有2个方法methods:方法表集合,一组方法表类型数据的集合。方法表结构和字段表结构一样。属性表集合:
与Class文件中其它数据项对长度、顺序、格式的严格要求不同,属性表集合不要求其中包含的属性表具有严格的顺序,并且只要属性的名称不与已有的属性名称重复,任何人实现的编译器可以向属性表中写入自己定义的属性信息。虚拟机在运行时会忽略不能识别的属性,为了能正确解析Class文件,虚拟机规范中预定义了虚拟机实现必须能够识别的9项属性在介绍类的加载机制之前,先来看看,类的加载机制在整个java程序运行期间处于一个什么环节,下面使用一张图来表示:
从上图可以看,java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。
其实可以一句话来解释:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。
注意:只有解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的 。
1、加载
”加载“是”类加机制”的第一个过程,在加载阶段,虚拟机主要完成三件事:
通过一个类的全限定名来获取其定义的二进制字节流将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构在堆中生成一个代表这个类的Class对象,作为方法区中这些数据的访问入口。2、验证
验证的主要作用就是确保被加载的类的正确性。也是连接阶段的第一步。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。他主要是完成四个阶段的验证:文件格式的验证、元数据验证、字节码验证、符号引用验证。
3、准备
准备阶段主要为类变量分配内存并设置初始值。这些内存都在方法区分配。在这个阶段我们只需要注意两点就好了,也就是类变量和初始值两个关键词。
4、解析
解析阶段主要是虚拟机将常量池中的符号引用转化为直接引用的过程。(符号引用就相当于老师用学号叫你而不是名字,直接引用则相反)
5、初始化
这是类加载机制的最后一步,在这个阶段,java程序代码才开始真正执行。我们知道,在准备阶段已经为类变量赋过一次值。在初始化阶端,程序员可以根据自己的需求来赋值了。一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。
他的工作流程是: 当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,才会尝试执行加载任务。
优点:
可以避免重复加载,父类已经加载了,子类就不需要再次加载更加安全,很好的解决了各个类加载器的基础类的统一问题,如果不使用该种方式,那么用户可以随意定义类加载器来加载核心api,会带来相关隐患。原则:
遵守双亲委派模型:继承ClassLoader,重写findClass()方法。破坏双亲委派模型:继承ClassLoader,重写loadClass()方法。 通常我们推荐采用第一种方法自定义类加载器,最大程度上的遵守双亲委派模型。实现步骤:
创建一个类继承ClassLoader抽象类重写findClass()方法在findClass()方法中调用defineClass()MyClassLoader类:
package com.houbo.myclassloader; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; public class MyClassLoader extends ClassLoader { private String libPath; public MyClassLoader(String path) { libPath = path; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { //Test.class String fileName = getFileName(name); //file = D:\Program Files (x86)\Idea\IntelliJ IDEA Community Edition 2019.1\lib\Test.class File file = new File(libPath,fileName); try { FileInputStream is = new FileInputStream(file); ByteArrayOutputStream bos = new ByteArrayOutputStream(); int len = 0; try { while ((len = is.read()) != -1) { bos.write(len); } } catch (IOException e) { e.printStackTrace(); } byte[] data = bos.toByteArray();//把类文件转化成字节数组 is.close(); bos.close(); return defineClass(name,data,0,data.length); } catch (IOException e) { e.printStackTrace(); } return super.findClass(name); } //获取要加载 的class文件名:com.houbo.myclassloader.Test-->Test.class private String getFileName(String name) { int index = name.lastIndexOf('.'); if(index == -1){ return name+".class"; }else{ return name.substring(index+1)+".class";//Test.class } } }main方法类:
package com.houbo.myclassloader; public class Main { public static void main(String[] args) { //加载class文件,注意是com.fdd.TestClass // MyClassLoader diskLoader = new MyClassLoader("D:\\Program Files (x86)\\Idea\\IntelliJ IDEA Community Edition 2019.1\\lib"); try { Class<?> c = diskLoader.loadClass("com.houbo.myclassloader.Test"); Object o = c.newInstance(); System.out.println(o.toString());//打印toString方法 } catch (Exception e) { e.printStackTrace(); } } }Test测试类(即被测试加载的类)
package com.houbo.myclassloader; public class Test { public static void main(String[] args) { //没有实现代码 } }运行结果:
在类加载的解析阶段,会将其中一部分符号引用直接转化为直接饮用,前提是:方法在程序真正运行之前就有一个可确定的版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
符合这个要求的方法,主要包括:静态方法、私有方法、构造方法、final修饰方法、父类方法。因为这两个方法的特点就决定了他们都不可能通过继承或别的方式重写其他版本。
--静态分派
依赖于静态类型来定位方法执行版本的分派动作(如重载)称为静态分派。
package com.houbo.dispatch; /** * 方法静态分派演示 * @author houbo */ public class StaticDispatch { static abstract class Human { } static class Man extends Human { } static class Woman extends Human { } public void sayHello(Human guy) { System.out.println("hello,guy!"); } public void sayHello(Man guy) { System.out.println("hello,gentleman!"); } public void sayHello(Woman guy) { System.out.println("hello,lady!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch sr = new StaticDispatch(); sr.sayHello(man); sr.sayHello(woman); /** * 输出: * hello,guy! * hello,guy! */ } }--动态分派
运行时期,依赖于实际类型来定位方法执行的分派动作(重写 Override) 属于动态分派。
package com.houbo.dispatch; /** * @author houbo */ public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("man say hello"); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("woman say hello"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); /** * 输出: * man say hello * woman say hello * woman say hello */ } }--单分派和多分派
方法的调用者与方法的参数统称为方法的宗量。根据分派基于多少宗量,可以将分派划分为单分派和多分派两种。
package com.houbo.dispatch; /** * @author houbo 多分派例子 */ public class Dispatch { static class QQ {} static class _360 {} public static class Father { public void hardChoice(QQ arg) { System.out.println("father choose qq"); } public void hardChoice(_360 arg) { System.out.println("father choose 360"); } } public static class Son extends Father { @Override public void hardChoice(QQ arg) { System.out.println("son choose qq"); } @Override public void hardChoice(_360 arg) { System.out.println("son choose 360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); /** * 输出: * father choose 360 * son choose qq */ } }因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
public class VolatileExample { int x = 0 ; volatile boolean v = false; public void writer(){ x = 42; v = true; } public void reader(){ if (v == true){ // 这里x会是多少呢 } } }问题:假设有两个线程A和B,A执行了writer方法,B执行reader方法,那么B线程中独到的变量x的值会是多少呢?
解答:根据下列规则可以得出
线程A中,根据规则一,对变量x的写操作是happens-before对变量v的写操作的,根据规则二,对变量v的写操作是happens-before对变量v的读操作的,最后根据规则三,也就是说,线程A对变量x的写操作,一定happens-before线程B对v的读操作,那么线程B在注释处读到的变量x的值,一定是42.
一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。
顺序性是指,我们可以按照顺序推演程序的执行结果,但是编译器未必一定会按照这个顺序编译,但是编译器保证结果一定==顺序推 演的结果。
volatile规则对一个volatile变量的写操作,happens-before后续对这个变量的读操作。
传递性规则如果A happens-before B,B happens-before C,那么A happens-before C。
线程中的锁规则对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
线程start()规则主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens before 线程B中的操作。
线程join()规则主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作, happens-before join()的返回。
下面对锁释放和锁获取的内存语义做个总结:
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。volatile变量自身具有以下特性:
可见性(最重要的特性)。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。原子性。对任意(包括64位long类型和double类型)单个volatile变量的读/写具有原子性。但是类型于a++这种复合操作不具有原子性。 package com.houbo; /** * @author houbo */ class Example { private volatile boolean stop = false; public void execute() { int i = 0; System.out.println("thread1 start loop."); while(!getStop()) { i++; } System.out.println("thread1 finish loop,i=" + i); } public boolean getStop() { return stop; // 对普通变量的读 } public void setStop(boolean flag) { this.stop = flag; // 对普通变量的写 } } public class VolatileExample { public static void main(String[] args) throws Exception { final Example example = new Example(); Thread t1 = new Thread(new Runnable() { @Override public void run() { example.execute(); } }); t1.start(); Thread.sleep(1000); System.out.println("主线程即将置stop值为true..."); example.setStop(true); System.out.println("主线程已将stop值为:" + example.getStop()); System.out.println("主线程等待线程1执行完..."); t1.join(); System.out.println("线程1已执行完毕,整个流程结束..."); /** * 输出结果: * thread1 start loop. * 主线程即将置stop值为true... * 主线程已将stop值为:true * 主线程等待线程1执行完... * ================================================= * 当把stop修饰成volatile时输出结果: * thread1 start loop. * 主线程即将置stop值为true... * thread1 finish loop,i=1911335273 * 主线程已将stop值为:true * 主线程等待线程1执行完... * 线程1已执行完毕,整个流程结束... */ } }可见得:
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
在旧的java内存模型中,线程可能会看到final域的值会改变,for example 一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读取这个final域的值的时候,会发现值变为了1(被其他线程初始化之后的值)。
JSR-133 专家组增强了final域的语义,即通过为final域添加写和读的重排序规则,从而达到为java程序员提供初始化安全保证:只要对象是正确构造的,那么不需要使用同步,就可以保证任意线程都能看到这个final域在构造函数中被初始化后的值。
结束得封装线--------------------------------------------------------------------------------------------------------------------------------------over by you loved
本篇博客正文较长,我们从java虚拟机整个技术体系细节进行总结,涵盖了jvm、对象加载、类加载、字节码文件、字节码指令、jvm底层特性及原理等各方面知识,对《深入理解java虚拟机》进行了全面的分析解读。如果有不对的地方望大佬留言指正。