3个区域线程私有(不需要垃圾回收,因为它们随着线程结束而自动销毁),2个区域所有线程共享(需要垃圾收集回收)
程序计数器(Programmer Counter Register):一块很小的内存,可以看做当前线程所执行的字节码的行号计数器。
Java虚拟机栈(Java Virtual Machine Stack)
我们常说的 ”堆“ 和 “栈” 中的 栈
描述的是Java方法执行的线程内存模型:每个方法执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
线程私有,生命周期与线程一样
两类异常情况
本地方法栈(Native Method Stacks)
Java堆(Java Heap)
方法区(Method Area)
各个线程共享
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
不需要连续内存空间
可选择固定大小或者可扩展
可选择不实现垃圾收集,垃圾收集行为在这个区域出现得比较少
内存回收目标主要针对:常量池回收(废弃常量),对类型的卸载(不再使用的类型)
当方法区无法满足新的内存分配需求时,会出现OutOfMemoryError异常
运行时常量池(Runtime Constant Pool)
方法区的一部分
(Java文件被编译成class文件)Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表
常量池表用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到**方法区的运行时常量池(**每个类都有一个运行时常量池)中!!!!!!
动态性:Java语言不要求常量只有编译期才能产生。即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中(如String类的intern()方法)
直接内存(DirectMemory)
不属于虚拟机运行时数据区的一部分,但也会被频繁使用
在HotSpot中,对象在堆内存中的存储布局划分为:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
Java程序通过栈上的reference数据来操作堆上的具体对象。
对象访问方式由具体的虚拟机实现而定
访问方式:
使用句柄:reference中存储的是对象的句柄地址
好处:reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普通的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改
直接指针:reference中存储的是对象地址
好处:速度快,节省一次指针定位的时间开销(HotSpot中主要使用这种方式进行对象访问)
dump:转储。动态(易失)的数据,保存为静态的数据(持久数据)
1、为什么要dump(dump的目的)?
因为程序在计算机中运行时,在内存、CPU、I/O等设备上的数据都是动态的(或者说是易失的),也就是说数据使用完或者发生异常就会丢掉。如果我想得到某些时刻的数据(有可能是调试程序Bug或者收集某些信息),就要把他转储(dump)为静态(如文件)的形式。否则,这些数据你永远都拿不到。
解决方法:通过内存影响分析工具堆Dump出来的堆转储快照进行分析
HotSpot虚拟机中不区分虚拟机栈和本地方法栈,栈容量大小仅由-Xxs参数设定
两种异常:
操作系统分配给每一个进程的内存时有限制的,譬如32位Windows的单个进程最大内存是2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两个部分的内存的最大值,那剩下的内存限制即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果再把直接内存和虚拟机进程本身消耗的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配。
所以每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易将剩下的内存耗尽。
java在加载sun.misc.Version这个类的时候进入常量池中。
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来
垃圾收集器(Garbage Collection, GC)
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每个栈帧中分配多少内存基本上是在类结构确定下来是就已知的。因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就随着回收了。
Java堆和方法区则有着显著的不确定性
堆中存放这几乎所有的实例对象,垃圾收集器在对堆进行回收之前,首先要确定哪些还“存活”着,哪些已经“死去”。
(死去: 即不可能再被任何途径使用到的对象)
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
优点:原理简单,判定效率也很高
缺点:这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作。譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。(在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存)
testGC()方法:对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。
当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
基本思路:就是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
在本地方法栈中JNI(Java Native Interface,即通常所说的Native方法)引用的对象。
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
所有被同步锁(synchronized关键字)持有的对象。
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。
内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为:
强引用(Strongly Reference)
软引用(Soft Reference)
弱引用(Weak Reference)
那些非必须对象,但是它的强度比软引用更弱一些
被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
WeakReference类来实现弱引用
虚引用(Phantom Reference)
为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
PhantomReference类来实现虚引用。
Phantom [fæntəm] n.
这4种引用强 度依次逐渐减弱。
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段
要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记
随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
对象可以在被GC时自我拯救
这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
方法区的垃圾收集主要回收两部分内容:
回收废弃常量与回收 Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
**判定一个类型是否属于“不再被使用的类”**需要同时满足下面三个条件:
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
当前商业虚拟机的垃圾收集器,大多数都遵循了**“分代收集”(Generational Collection)的理论**进行设计
**设计原则:**收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域
“Minor GC”“Major GC”“Full GC”
标记-复制算法
标记-清除算法
标记-整理算法
新生代 & 老年代
新生代:标记-复制算法
老年代:标记-清除 或者 标记-整理算法
对象不是孤立的,对象之间会存在跨代引用。
存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
(1)解决跨代引用:
我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方 法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
通常能单独发生收集行为的只是新生代,所以这里“反过来”的情况只是理论上允许,实际上除了CMS收集器,其他都不存在只针对老年代的收集。
法1:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象
法2:也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点:
半区复制:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
特点:实现简单,运行高效
缺点:空间浪费严重
更优化的版区复制策略—Apple式回收
新生代 = Eden空间 + From Survivor空间 + To Survivor空间 (8 : 1 : 1)
**or **新生代 = Eden空间 + 2 * Survivor空间
每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
**HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,**即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
**当Survivor空间不足以容纳一次Minor GC之后存活的对象时,**就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。 (一般就将它们复制到老年代去)
缺点:
首先标记出所有存活的对象,是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
缺点:如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存访问是用户最频繁的操作(大量内存访问势必会直接影响应用程序的吞吐量)
移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点
一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
JNI----Java Native Interface
保障一致性
收集器在对象图上标记颜色,同时用户线程在修改引用 关系——即修改对象图的结构,这样可能出现两种后果。
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
赋值器插入了一条或多条从黑色对象到白色对象的新引用;
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。
由此分别 产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。
增量更新:要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
serial:adj:一系列的,连续的,顺序排列的,单线程的
Serial收集器依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余与Serial收集器完全一致
它是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器
除了Serial收集器外,目前只有ParNew收集器能与CMS收集器(老年代的收集器)配合工作
并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
Parallel: adj.平行的,并行的
Scavenge: [ˈskævɪndʒ] v. (从废弃物中)觅食; 捡破烂; 拾荒;
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验
高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务
吞吐量:就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
ratio:n.比率,比例
是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作
供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
直到JDK 6时才开始提供的。
Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的
直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
Concurrent Mark Sweep
是一种以获取最短回收停顿时间为目标的收集器。
目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
缺点:
简称G1:开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器
Deprecate : [ˈdeprəkeɪt] v.不赞成,强烈反对
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。G1不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,
如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。
G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的
可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
它默认的停顿目标为两百毫秒。
衡量垃圾收集器的三项最重要的指标是:
延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。
浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的
Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定 的,与堆的容量、堆中对象的数量没有正比例关系。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。
各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——**字节码(**Byte Code)是构成平台无关性的基石
时至今日,商业企业和开源机构已经在Java语言之外发展出一大批运行在Java虚拟机之上的语言,如Kotlin、Clojure、Groovy、JRuby、JPython、Scala等
实现语言无关性的基础仍然是虚拟机和字节码存储格式
Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。
作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品的交付媒介。
Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没
任何一个Class文件都对应着唯一的一个类或接口的定义信息[1],但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。
Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符
Class文件格式:
无符号数
表
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名
都习惯性地以“_info”结尾。
每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。
紧接着主、次版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。
常量池容量:0x0016(16进制),即十进制的22,这代表常量池中有21项常量,索引值范围是1~21。
常量池的容量计数是从1而不是0开始的。目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。
常量池的存储的两大类常量:
在虚拟机加载Class文件的时候进行动态连接
在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
tag是标志位,它用于区分常量类型;name_index是常量池的索引值,它指向常量池中一个 CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,本例中的name_index值(偏移地址:0x0000000B)为0x0002,也就是指向了常量池中的第二项常量。
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。
在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据
接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。
字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。
方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚
方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为**“Code”的属性**里面,属性表作为Class文件格式中最具扩展性的一种数据项目,
Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。
太多了 不想看
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。
Java虚拟机采用面向操作数栈而不是面向寄存器的架构
虚拟机的类加载机制:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制
与那些在编译时需要进行连接的语言不同
在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的
Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的
类的生命周期(从被加载到虚拟机内存开始,到卸载出为止):
验证、准备、解析三个部分统称为连接(Linking)
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
必须初始化的场景(主动引用)
被动引用
加载、验证、准备、解析和初始化5个步骤
1)通过一个类的全限定名来获取定义此类的二进制字节流。 (它并没有指明二进制字节流必须得从某个 Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取)
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存(堆)中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
数组类型和非数组类型有区别。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:(略)P365
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
- 类 和 数组加载过程的区别?
数组也有类型,称为“数组类型”。如:
String[] str = new String[10];
这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。
当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类,再由类加载器创建数组中的元素类。
而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。
验证是连接阶段的第一步
目的:确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
四个阶段:
验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备阶段:正式为类中定义的静态变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
(这里所说的初始值“通常情况”下是数据类型的零值。如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值)
概念上讲,这些变量所使用的内存都应当在方法区中进行分配,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;
在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值 为123的动作要到类的初始化阶段才会被执行
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置将value赋值为123。
解析阶段:是Java虚拟机将常量池内的符号引用替换为直接引用的过程
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行(共8种)
针对上面第3点访问权限验证,在JDK 9引入了模块化以后,一个public类型也不再意味着程序任何位置都有它的访问权限,我们还必须检查模块间的访问权限。
前提:解析字段所属的类或者接口的符号引用。
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。
java.lang.NoSuchFieldError异常
前提:解析方法所属的类或者接口的符号引用。是需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用
java.lang.NoSuchMethodError
前提:解析接口方法所属的类或者接口的符号引用。是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用
5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
在JDK 9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。但在JDK 9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError异常。
在编译生成class文件时,会自动产生两个方法,一个是类的初始化方法clinit(), 另一个是实例的初始化方法init()
clinit():在jvm第一次加载class文件时调用,包括静态变量初始化语句和静态块的执行
init():在实例创建出来的时候调用,包括调用new操作符;调用Class或java.lang.reflect.Constructor对象的newInstance()方法;调用任何现有对象的clone()方法;通过java.io.ObjectInputStream类的getObject()方法反序列化。
类的初始化阶段是类加载过程的最后一个步骤,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。
初始化阶段就是执行类构造器clinit()方法的过程。clinit()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。
clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
初始化阶段就是执行类构造器clinit()的过程。
clinit()方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。??(显式初始化)
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
()方法与类的构造函数(即在虚拟机视角中的实例构造器**()方法**)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行 完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。
由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 ()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法。
Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步
同一个类加载器下,一个类型只会被初始化一次
类加载阶段:通过一个类的全限定名来获取描述该类的二进制字节 流。
把这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
各种类加载器之间的层次关系被称为类加载器的**“双亲委派模型**(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用 组合(Composition)关系来复用父加载器的代码。
**双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,**每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
好处
双亲委派模型的实现:
在java.lang.ClassLoader的loadClass()方法之中
先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。
在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。
Java的模块定义包含以下内容:
可配置的封装隔离机制首先要解决
JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。
还解决了原来类路径上跨JAR文件的public类型的可访问性问题
为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与“类路径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。
只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;
只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,它也仍然会被当作一个模块来对待。
模块化下的类加载器的变动:
JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。
计算机的存储设备与处理器的运算速度有着几个数量级的差距
现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。
多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、 Synapse、Firefly及Dragon Protocol等
Java Memory Model,JMM
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
这里所讲的主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的
如果两者一定要勉强对应起来
从定义上看:
从更基础的层次上说
交互
Java内存模型中定义了以下8种操作
规则:
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制
volatile [ˈvɒlətaɪl] adj.易变的; 无定性的; 无常性的; 不稳定的;
原子性是世界上最小单位,具有不可分割性。比如a=0;(a非long和double类型)这个操作是不可分割的,那么我们说这个操作是原子操作。再比如:a++;这个操作实际上是a=a+1;是可分割的,所以他不是一个原子操作。
非原子操作都会存在线程安全问题,需要我们使用**同步技术(**sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们就称它具有原子性。
一个变量被定义成volatile之后,它将具备两项特性
保证此变量对所有线程的可见性
禁止指令重排序优化
保证代码的执行顺序与程序的顺序相同
**重排序优化:**机器级的优化操作,提前执行是指这条语句对应的汇编代码被提前执行
指令之间的依赖性
关键变化在于有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障(volatile修饰的变量,在翻译成汇编语言的时候,会有一个LOCK前缀的指令)
内存屏障 (Memory Barrier或Memory Fence):指重排序时不能把后面的指令重排序到内存屏障之前的位置
只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了
volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
lock addl$0x0,(%esp)
于lock前缀,查询IA32手册可知,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存,这种操作相当于对缓存中的变量做了一次前面 介绍Java内存模式中所说的“store和write”操作。所以通过这样一个空操作(“addl$0x0,(%esp)”(把ESP寄存器的值加0)),可让前面volatile变量的修改对其他处理器立即可见
假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:
volatile关键字介绍
在java中提供了volatile关键字,通过volatile关键字修饰内存中的变量,该变量在线程之间共享。
volatile关键字是轻量级的锁(synchronized)。在使用的时候,消耗的成本比synchronized小很多。volatile用于修饰变量。
volatile实现原理
volatile修饰的变量,在翻译成汇编语言的时候,会有一个LOCK前缀的指令。
LOCK前缀的指令在多核处理器下会引发两件事情。
- 将当前处理器缓存行的数据会写回到系统内存。
- 这个写回内存的操作会引起在其他CPU里缓存该内存地址的数据无效。
如果对声明了volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
volatile的适用范围
volatile变量固然方便,但是存在着限制,volatile修饰的变量,并不能保证是原子操作的,所以多处理器操作数据时,会导致数据重复。所以volatile关键字通常被当作完成、中断的状态的标识使用。
//DCL单例模式
instance = new Singleton();
//这个new 并且赋值的语句在jvm中其实可以抽象成三条指令
memory = allocate(); //1:给对象开辟一块内存
initInstance(memory); //2:初始化对象
instance = memory; //3:instance指向分配好的内存
当线程A执行到对象引用执行分配好的内存时,这时对象还未初始化,线程B此时调用getInstance()方法,判断引用已经不为null,因此直接返回,此时对象是半初始化状态,使用会导致异常出现。
解决该问题的方法可以使用volatile修饰成员变量instance,volatile可以通过内存屏障防止上述的指令重排序问题。
硬件层面的内存屏障分为Load Barrier 和 Store Barrier即读屏障和写屏障。
对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
下面是基于JMM内存屏障的插入策略:
在每个volatile写操作的前面插入一个storestore屏障。
在每个volatile写操作的后面插入一个storeload屏障。
在每个volatile读操作的后面插入一个loadload屏障。
在每个volatile读操作的后面插入一个loadstore屏障。
instance = new Singleton();
会被编译器编译成如下JVM指令:
- memory = allocate(); //分配对象的内存空间
- ctorInstance(memory); //初始化对象
- instance = memory; //设置instance指向刚分配的内存空间
但是这些指令排序并非一成不变,有可能会经过JVM和CPU的优化,指令重排为如下顺序:
- memory = allocate(); //分配对象的内存空间
- instance = memory; //设置instance指向刚分配的内存空间
- ctorInstance(memory); //初始化对象
当线程A执行完1,3,时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象。
实例化对象实际会分为3个步骤:
但有的编译器由于性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:
使用了volatile关键字之后,重排序被禁止,所有的写操作(write)都发生在读操作(read)之前。
允许虚拟机将没有被volatile修饰的64位数据(long,double)的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。
原子性:是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。
可见性是指:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。
有序性:即程序执行的顺序按照代码的先后顺序执行。
先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。
实现线程主要有三种方式:
使用内核线程实现(1:1实现)
使用用户线程实现(1:N实现)
使用用户线程加轻量级进程混合实现(N:M实现)
Java线程的实现
线程调度:指系统为线程分配处理器使用权的过程
协同式(Cooperative Threads-Scheduling)线程调度
抢占式(Preemptive Threads-Scheduling)线程调度
每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定
Java使用的线程调度方式
可设置线程优先级,优先级越高的线程越容易被系统选择执行
内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提。因为如果根本不存在多线程,又或者一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是没有什么区别的。
Java语言中各种操作共享的数据分为以下五类:(线程安全的“安全程度”由强至弱来排序)
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。
synchronized关键字经过Javac编译之后,会在同步块的前后分别形成 monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。在第10章中我们知道了在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。
JDK 5起(实现了JSR 166),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的 java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步
重入锁(ReentrantLock)是Lock接口最常见的一种实现
高级功能,主要有以下三项:
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。
基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数 据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。
比较并交换(Compare-and-Swap,下文称CAS)
可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。
Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字将它声明为“易变的”;
一个变量只要被某个线程独享,通过java.lang.ThreadLocal类来实现线程本地存储的功能。。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁在JDK 6中就已经改为默认开启。
因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
在 JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。(如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。)
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
HotSpot虚拟机的对象头(Object Header)
加锁工作过程
解锁过程也同样是通过CAS操作来进行的
如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
[外链图片转存中…(img-l1F8DMl2-1628430706015)]
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
HotSpot虚拟机的对象头(Object Header)
[外链图片转存中…(img-Q3yLjSLC-1628430706017)]
加锁工作过程
解锁过程也同样是通过CAS操作来进行的
如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。