JVM虚拟机笔记

一、运行时数据区域

运行时数据区域:(p39)

1、程序计数器(线程隔离):当前线程所执行的字节码的行号指示器,通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。

2、栈区:

栈分为java虚拟机栈和本地方法栈

java虚拟机栈(线程隔离):每个方法在运行时都会创建一个栈帧,栈帧存储局部变量表、操作数栈、动态链接、方法出口等信息。其中最关键的局部变量表(我们通常所说的栈)存着编译期可知的各种基本数据类型(boolean,int)、对象引用等。

本地方法栈(线程隔离):这部分主要与虚拟机用到的 Native 方法相关,一般情况下, Java 应用程序员并不需要关心这部分的内容。

3、堆区(线程共享):唯一的目的就是存放对象实例

java堆是gc的主要区域,通常情况下分为新生代老年代。更细致分为:Eden、From Survivor、To Survivor空间。

线程共享的java堆中可能划分出多个线程隔离的分配缓冲区

4、方法区(线程共享):用于存放已被虚拟机加载类信息,常量,静态变量等数据。

被Java虚拟机描述为的一个逻辑部分。也被称为“永生代”(permanment generation)

注:JDK1.7中,存储在永久代的部分数据就已经转移到了堆中,而到了JDK 1.8 ,使用元空间取代了永生代。

线程请求的栈深度过大:StackOverflowError

无法申请到足够的内存:OutOfMemoryError

面试问题:

1.JVM中堆空间可以分成三个大区,新生代、老年代、永久代

2.新生代可以划分为三个区,Eden区,两个幸存区

在JVM运行时,可以通过配置以下参数改变整个JVM堆的配置比例

1.JVM运行时堆的大小

  -Xms堆的最小值

  -Xmx堆空间的最大值

2.新生代堆空间大小调整

  -XX:NewSize新生代的最小值

  -XX:MaxNewSize新生代的最大值

  -XX:NewRatio设置新生代与老年代在堆空间的大小

  -XX:SurvivorRatio新生代中Eden所占区域的大小

3.永久代大小调整

  -XX:MaxPermSize
 

二、hotspot虚拟机对象

2.1 对象的创建

1.检查 

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2.分配内存 

接下来将为新生对象分配内存,为对象分配内存空间的任务等同于把一块确定的大小的内存从Java堆中划分出来。

假设Java堆中内存是绝对规整的,所有用过的内存放在一遍,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把指向空闲空间的指针挪动一段与对象大小相等的距离,这个分配方式叫做“指针碰撞”

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式成为“空闲列表”

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. Init

执行new指令之后会接着执行Init方法,进行初始化,这样一个对象才算产生出来

2.2 对象的内存布局(p47)

对象分为对象头实例数据对齐填充

对象头包括两部分:

a) 对象自身的运行时数据,Hash、GC分带年龄、锁等,长度为32bit或64bit

b) 类型指针,即对象指向类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例

2.3 对象的访问定位(p49)

a)使用句柄访问

Java堆中将会划分出一块内存来作为句柄池reference中存储的就是对象的句柄地址,而句柄中包含了实例类型指针

优势:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改

b)使用直接指针访问

Java堆对象的布局就必须考虑如何访问类型数据的相关信息,而refreence直接存储对象的地址

优势:速度更快,节省了一次指针定位时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本

三、OutOfMemoryError 异常(了解)

3.1 Java堆溢出

Java堆用于存储对象实例,只要不断的创建对象,并且保证GCRoots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在数量到达最大堆的容量限制后就会产生内存溢出异常

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置

如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗

3.2 虚拟机栈和本地方法栈溢出

对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError

如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常

在单线程下,无论由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常

如果是多线程导致的内存溢出,与栈空间是否足够大并不存在任何联系,这个时候每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。解决的时候是在不能减少线程数或更换64为的虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程

3.3 方法区和运行时常量池溢出

String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用

由于常量池分配在永久代中,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。

Intern():

JDK1.6 intern方法会把首次遇到的字符串实例复制到永久代,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是一个引用

JDK1.7 intern()方法的实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个

四、垃圾收集

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了

1.判断对象存活

4.1.1 引用计数器法

给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的

4.1.2 可达性分析算法(主流)

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的

Java语言中GC Roots的对象包括下面几种:

1.虚拟机栈(栈帧中的本地变量表)中引用的对象

2.本地方法栈(Native方法)引用的对象

3.方法区中类静态属性引用的对象

4.方法区中常量引用的对象

2.引用

强引用就是在程序代码之中普遍存在的,类似Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

软引用用来描述一些还有用但并非必须的元素。在OutOfMemoryError抛出之前,会把这些对象进行回收,如果回收后还没有足够的内存才会抛出内存溢出异常

弱引用:只能生存到下一次垃圾回收发生之前

虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

3.一个对象“死亡”需要满足三点:

① “GC Roots”不可达

② 对象中有finalize()方法,且未被虚拟机调用过(若被调用过,下一次直接gg)

③ 满足①②条件后,对象调用finalize(),且进入F-Queue中等待死亡,若在这个过程中没人捞他一手,就GG

4.垃圾收集算法

4.4.1 标记—清除算法

算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象、

不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清楚之后会产生大量不连续的内存碎片,下次程序运行需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

4.4.2 复制算法

他将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。内存分配时只需按顺序分配即可。

不足:将内存缩小为原来的一半

实际中我们并不需要按照1:1比例来划分内存空间,而是将内存分为一块较大的Eden空间两块较小的Survivor空间,每次使用Eden和其中一块Survivor

当另一个Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代

4.4.3 标记整理算法

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

4.4.4 分代收集算法

a) 所有新生成的对象优先都是放在年轻代的(大的对象直接放入老年代),。

b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复(上述回收过程称为Minor GC)。

c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。

d)Minor GC中长期存活的(默认是15岁)对象将进入老年代

e) 持久代就是方法区。

总结:

新生代 ----> 复制算法,因为每次垃圾收集时都发现有大批对象死去,只有少量存活,只需要付出少量存活对象的复制成本就可以完成收集。

老年代 --->   标记清理或者标记整理算法,因为对象存活率高、没有额外空间对它进行分配担保。

5. GC是什么时候触发的

5.1 担保GC

  一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC(具体操作详见4.4.4 b)。

5.2 Full GC

  有如下原因可能导致Full GC:

a) 年老代(Tenured)被写满;

b) 持久代(Perm)被写满;

c) System.gc()被显式调用;

6.垃圾收集器(注意通过图来理解几个收集器的差异,同时不要太计较暂停和不暂停)

a)Serial收集器:

这个收集器是一个单线程的收集器,但它的单线程的意义不仅仅说明它会只使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时必须暂停其他所有的工作线程,直到它工作结束

b)ParNew 收集器:

Serial收集器的多线程版本,除了使用了多线程进行收集之外,其余行为和Serial收集器一样

并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

并发:指用户线程与垃圾收集线程同时执行(不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾收集程序运行于另一个CPU上

c)Parallel Scavenge 

是一个新生代收集器,所以使用的是复制算法,又是并行的多线程收集器。其目标是达到一个可控制的吞吐量

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

d)Serial Old 收集器:

是Serial收集器的老年代版本,所以使用标记整理算法,是一个单线程收集器,

e)Parallel Old 收集器:

Parallel Old是Paraller Seavenge收集器的老年代版本,所以使用标记整理算法,同时使用多线程。

f)CMS收集器:(concurrent mark sweep

CMS收集器是基于标记清除(mark sweep)算法实现的,整个过程分为4个步骤

初始标记(CMS initial mark) 
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。

并发标记(CMS concurrent mark) 
并发标记阶段就是进行GC Roots Tracing的过程。

重新标记(CMS remark) 
重新标记用户线程并发运行时 出现变动的 地方,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。

并发清除(CMS concurrent sweep) 
并发清除阶段会清除对象。

优点:并发收集、低停顿

缺点:

1.CMS收集器对CPU资源非常敏感,CMS默认启动的回收线程数是(CPU数量+3)/4,

2.CMS收集器无法处理浮动垃圾,可能出现Failure失败而导致一次Full GC产生

3.CMS是基于标记清除算法实现的(标记清除算法会产生大量空间碎片)

g)G1收集器:

概念:

1、使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),(虽然还保留有新生代和老年代的概念)。

2、G1跟踪各个Region里面的垃圾堆积的回收价值和成本(回收所获得的空间大小及回收所需时间的经验值),在后台维护一个优先列表,按优先级回收Region

初始标记、并发标记:同上。

最终标记(Final Marking) 
作用同重新标记,同时虚拟机会将对象的变化记录在Logs里面,并且会把Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。

筛选回收(Live Data Counting and Evacuation) 
筛选回收阶段首先对各个Region的回收价值和成本进行排序,按优先级回收Region。(这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率)。

6.内存分配与回收策略(了解)

4.6.1 对象优先在Eden分配:

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

4.6.2 大对象直接进入老年代

所谓大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。这样做的目的是避免Eden区及两个Servivor之间发生大量的内存复制

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

如果对象在Eden区出生并且尽力过一次Minor GC后仍然存活,并且能够被Servivor容纳,将被移动到Servivor空间中,并且把对象年龄设置成为1.对象在Servivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会被晋级到老年代中

4.6.4动态对象年龄判定

为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋级到老年代,如果在Servivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入到老年代,无须登到MaxTenuringThreshold中要求的年龄

4.6.4 空间分配担保:

Minor GC 之前 ---> 老年代最大可用的连续空间 > 新生代所有对象总空间?Minor GC安全:情况A;

A:HandlePromotionFailure开启?情况B:FULL GC;

B:老年代最大可用的连续空间 > 历次晋级到老年代对象的平均大小:冒险Minor GC:FULL GC;

五、类文件结构(优先级低)

1 概述

Java虚拟机不和包括Java在内的任何语言绑定,只与 "Class文件" 这种特定的二进制文件所关联。 

2 Class类文件结构

Class文件是一组以8位字节为基础的二进制流。当遇到需要占用8位字节以上空间时,则会按照高位在前的方式分割成若干个8位字节进行存储。

Class文件格式采用一种类似于C语言结构体的伪结构来存储,这种伪结构有两种数据类型:无符号数

这里需要重复提一下,Class文件结构不像XML等描述语言,由于它没有任何分割符号,所以无论是数量甚至于数据存储的字节序这样的细节都被严格限定。

2.1 魔数与Class文件版本

每个Class文件的头四个字节称为魔数(Magic Number),它起到校验的作用,也就是确定这个文件是否能被虚拟机接受。紧接着5-8字节存储的是Class文件的版本号:5-6是次版本号,7-8是主版本号

2.2 常量池

紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。常量池主要存放两大常量:字面量符号引用字面量比较接近于java语言层面的的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:

  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符

2.3访问标志

在常量池结束之后,紧接着的两个字节代表访问标志,表示这个Class是类还是接口,是否为public或者abstract类型等。具体标志位及标志的含义如下图所示:

2.4 类索引、父类索引与接口索引集合

类索引父类索引接口索引集合都按顺序排列在访问标志之后,Class文件由这三项数据来确定这个类的继承关系类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于java语言的单继承,所以父类索引只有一个,除了java.lang.Object之外,所有的java类都有父类,因此除了java.lang.Object外,所有java类的父类索引都不为0接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implents(如果这个类本身是接口的话则是extends)后的接口顺序从左到右排列在接口索引集合中。

2.5 字段表集合

字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量

2.6方法表集合(随便看看)

与字段表几乎采用了完全一致的方式。因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志

2.7 属性表结合

3 字节码指令简介

3.1字节码与数据类型

在java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息,例如iload指令用于从局部变量表中加载int类型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能是同一段代码实现的,但在Class文件中它们必须拥有各自独立的操作码。

大部分的指令都没有支持整数类型byte、char、short、boolean类型。实际上都是使用相应的int作为运算符类型。

3.2 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表操作数栈之间来回传输

3.3 运算指令

运算或算术指令用于对操作数栈上的值进行某种特定运算,并把结果重新存入操作栈顶。 大体上算术指令可以分为两种:对整型数据和对浮点数据进行运算指令。(由于没有byte、char、short、boolean类型,所以对这类数据的运算应使用int类型指令代替)

3.4 类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换。(比如int类型转换为float类型) 小范围到大范围类型安全转换,无需显式的转换指令,否则必须显式的使用转换指令来完成。

3.5 对象创建与访问指令

虽然类实例和数组都是对象,但java虚拟机对类实例和数组的创建和操作使用了不同的字节码指令。

3.6 操作数栈管理指令

如同操作数据结构中的栈一样,java虚拟机也提供了一些用于直接操作操作数栈的指令。

3.7 控制转移指令

可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。

3.8 方法调用和返回指令

  • invokevirtual 指令用于调用对象的实例方法

  • invokeinterface指令用于调用接口方法

  • invokespecial指令用于调用一些需要特殊处理的实例方法

  • invokestatic指令用于调用static方法

  • invokedynamic指令用于在运行时动态解析出调用点限定符所使用的方法。

方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的。

3.9 异常处理指令

在java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的,而是采用异常表的方式。

3.10 同步指令

java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构使用管程(Monitor)来支持的。

六、类加载机制(重要)

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的

6.1 类加载的时机

类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这个是为了支持Java语言运行时绑定(也成为动态绑定或晚期绑定)

主动引用:

虚拟机规范规定有且只有5种情况即主动引用)必须立即对类进行初始化:

1.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候

2.使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化

3.当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

4.当虚拟机启动时候,虚拟机会先初始化包含main()方法的那个类

5.当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

被动引用:

1.通过子类引用父类的静态字段,不会导致子类初始化

2.通过数组定义来引用类,不会触发此类的初始化

3.常量不会触发定义常量的的初始化,因为常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类。

接口的初始化

接口在初始化时,并不要求父接口全部完成类初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

6.2 类加载的过程(重要)

6.2.1 加载

1)通过一个类的全限定名类获取定义此类的二进制字节流

2)将这字节流所代表的静态存储结构转化为方法区运行时数据结构(静->动)

3)在内存中生成Class对象,作为方法区这个类的各种数据的访问入口


数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的

(数组类的创建过程遵循以下规则:

1)如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归单个类加载过程去加载,数组C将在加载该组件类型的类加载器的类名称空间上被标识

2)如果数组的组件类型不是引用类型(列如int[]组数),Java虚拟机将会把数组C标识为与引导类加载器关联

3)数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public)

6.2.2 验证

验证阶段会完成下面4个阶段的检验动作:文件格式验证,元数据验证,字节码验证,符号引用验证

1.文件格式验证

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

这个阶段的验证是基于二进制字节流进行的,只有通过验证后,字节流才会进入内存的方法区进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流

2.元数据验证(即与父类的关系)

1.这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)

2.这个类的父类是否继承了不允许被继承的类(被final修饰的类)

3.如果这个类不是抽象类,是否实现了父类或接口之中要求实现的所有方法

4.类中的字段、方法是否与父类产生矛盾

第二阶段的主要目的是对类元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

3.字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语言是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法运行时不会做出危害虚拟机安全的事件。

6.2.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量都在方法区中进行分配。这个时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量实例变量将会在对象实例化时随着对象一起分配在Java堆中

假设public static int value = 123;

变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行,但是如果使用final修饰,则在这个阶段其初始值设置为123

6.2.4解析

解析阶段是虚拟机将常量池内符号引用替换为直接引用的过程

符号引用:用一组符号来描述所引用的目标

直接引用:可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

6.2.5 初始化

类的初始化阶段是类加载过程的最后一步,是执行类构造器<client>()方法的过程

6.3 类的加载器(重要)

6.3.1 类与类加载器

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。

6.3.2 双亲委派模型:

只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分。另一种其他类加载器,使用JAVA实现,独立于JVM,并且全部继承自抽象类java.lang.ClassLoader.

启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA+HOME>\lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是JVM识别的(仅按照文件名识别,如rt.jar,如果名字不符合,即使放在lib目录中也不会被加载),加载到虚拟机内存中,启动类加载器无法被JAVA程序直接引用。

扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器(Application ClassLoader),由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般称它为系统类加载器负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

这张图表示类加载器的双亲委派模型(Parents Delegation model). 双亲委派模型要求除了顶层的启动加载类外,其余的类加载器都应当有自己的父类加载器。,这里类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父类加载器的代码。

双亲委派模型的工作过程是:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

这样做的好处就是:

Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,如果用户自己编写了一个java.lang.object的类,并放在ClassPath中,那系统会出现多个Object类,应用程序会变得一片混乱

七、Java内存模型与线程

注:JAVA内存模型中所说的“变量”与java编程的变量有所不同,不包括局部变量和方法参数,因为它们是线程私有的。

7.1主内存与工作内存

Java内存模型规定所有的变量存储在主内存中,每条线程还有自己的工作内存。

7.2 内存间的交互操作

Java内存模型定义了以下八种操作来完成:(理解,不背)

lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存变量解锁才可以被其他线程锁定

read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

load(载入):作用于工作内存的变量,它把read操作得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,把工作内存中的变量值传送到主内存中。(只是负责传输)

write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺序德执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。注意,这里只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间, store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺 序是read a,read b,load b, load a。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:(理解)

1、不允许read和load、store和write操作之一单独出现

2、不允许一个线程丢弃它的最近的assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

3、不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

4、一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

5、一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

6、如果对一个变量执行lock操作,将会清空工作内存中此变量的值

7、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

8、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

7.3 对于volatile型变量的特殊规则

当一个变量定义为volatile之后,它将具备两种特性:

第一:保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,其他线程可以立即得知新值普通变量的值在线程间传递需要通过主内存来完成。

由于valatile只能保证可见性,在不符合以下两条规则的运算场景中,我们仍要通过加锁来保证原子性

1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

2.变量不需要与其他的状态变量共同参与不变约束

第二:禁止指令重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致,这个就是所谓的线程内表现为串行的语义

Java内存模型中对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store、write操作时需要满足如下的规则:

1.只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load操作。线程T对变量V的use操作可以认为是与线程T对变量V的load和read操作相关联的,必须一起连续出现。这条规则要求在工作内存中,每次使用变量V之前都必须先从主内存刷新最新值用于保证能看到其它线程对变量V所作的修改后的值

2.只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store操作;并且,只有当线程T对变量V执行的后一个动作是store操作的时候,线程T才能对变量V执行assign操作。线程T对变量V的assign操作可以认为是与线程T对变量V的store和write操作相关联的,必须一起连续出现。这一条规则要求在工作内存中,每次修改V后都必须立即同步回主内存中,用于保证其它线程可以看到自己对变量V的修改。

3.假定操作A是线程T对变量V实施的use或assign动作,假定操作F是操作A相关联的load或store操作,假定操作P是与操作F相应的对变量V的read或write操作;类型地,假定动作B是线程T对变量W实施的use或assign动作,假定操作G是操作B相关联的load或store操作,假定操作Q是与操作G相应的对变量V的read或write操作。如果A先于B,那么P先于Q这条规则要求valitile修改的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同

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

Java模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性

6.5 原子性、可见性和有序性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,valatile特殊规则保障新值可以立即同步到主内存中。Synchronized是在对一个变量执行unlock之前,必须把变量同步回主内存中(执行store、write操作)。被final修饰的字段在构造器中一旦初始化完成,并且构造器没有吧this的引用传递出去,那在其他线程中就能看见final字段的值

可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性:即程序执行的顺序按照代码的先后顺序执行

6.6  先行发生原则

这些先行发生关系无须任何同步就已经存在,如果不在此列就不能保障顺序性,虚拟机就可以对它们任意地进行重排序

1.程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说,应该是控制顺序而不是程序代码顺序,因为要考虑分支。循环等结构

2.管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而后面的是指时间上的先后顺序

3.Volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面同样是指时间上的先后顺序

4.线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

5.线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.joke()方法结束、ThradisAlive()的返回值等手段检测到线程已经终止执行

6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过Thread.interrupted()方法检测到是否有中断发生

7.对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

8.传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

6.7  Java线程调度

协同式调度:线程的执行时间由线程本身控制

抢占式调度:线程的执行时间由系统来分配

6.8 状态转换

1.新建

2.运行:可能正在执行。可能正在等待CPU为它分配执行时间

3.无限期等待:不会被分配CUP执行时间,它们要等待被其他线程显式唤醒

4.限期等待:不会被分配CUP执行时间,它们无须等待被其他线程显式唤醒,一定时间会由系统自动唤醒

5.阻塞:阻塞状态在等待这获取到一个排他锁,这个时间将在另一个线程放弃这个锁的时候发生;等待状态就是在等待一段时间,或者唤醒动作的发生

6.结束:已终止线程的线程状态,线程已经结束执行

七、线程安全(了解)

1、不可变:不可变的对象一定是线程安全的、无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障。例如:把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。

2、绝对线程安全:非常严格的定义,因此很难实现。

3、相对线程安全:相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性

4、线程兼容:对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全使用

5、线程对立:是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码

7.1 线程安全的实现方法

1.互斥同步:

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果:互斥是方法,同步是目的

在Java中,最基本的互斥同步手段就是synchronized关键字,它经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有指明,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,对应的在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,哪当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止

相比Synchronized,ReentrantLock增加了一些高级功能

1.等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助

2.公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;非公平锁则不能保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。Synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁

3.锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多余一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition方法即可

2.非阻塞同步

3.无同步方案

可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身)而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

判断一个代码是否具备可重入性:如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的

线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保障,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题

7.2锁优化

适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁

7.2.1 自旋锁与自适应自旋

自旋锁:如果物理机器上有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

自适应自旋转:是由前一次在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自过程,以避免浪费处理器资源。

7.2.2 锁消除

锁消除是指虚拟机即时编辑器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。如果在一段代码中。推上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行

7.2.3锁粗化

如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

7.2.4 轻量级锁

7.2.5 偏向锁

它的目的是消除无竞争情况下的同步原语,进一步提高程序的运行性能。如果轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把这个同步都消除掉,CAS操作都不做了

如果在接下俩的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要在进行同步

八、逃逸分析(了解)

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,成为方法逃逸。甚至还可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

如果一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化

栈上分配:如果确定一个对象不会逃逸出方法外,那让这个对象在栈上分配内存将会是一个不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。如果能使用栈上分配,那大量的对象就随着方法的结束而销毁了,垃圾收集系统的压力将会小很多

同步消除:如果确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉

标量替换:标量就是指一个数据无法在分解成更小的数据表示了,int、long等及refrence类型等都不能在进一步分解,它们称为标量。

如果一个数据可以继续分解,就称为聚合量,Java中的对象就是最典型的聚合量

如果一个对象不会被外部访问,并且这个对象可以被拆散的化,那程序正整执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替

猜你喜欢

转载自blog.csdn.net/bintoYu/article/details/83022806