深入理解JVM笔记

自动内存管理机制

运行时数据区

JVM运行时数据区分为:程序计数器,Java虚拟机栈,本地方法栈,java堆,方法区,运行时常量池,直接内存。

线程共享的区域有:方法区,堆,执行引擎,本地库接口

线程隔离的区域有:虚拟机栈,本地方法栈,程序计数器

程序计数器:可以看做是当前线程所执行的字节码的行号指示器

Java虚拟机栈:生命周期与线程相同,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表操作数栈动态链接方法出口等信息。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果虚拟机栈可以动态扩展且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常

局部变量表:存放了编译期可知的各种基本数据类型对象引用returnAddress类型longdouble类型的数据会占用2个局部变量空间,其余占1个,局部变量表的内存分配在编译期完成

本地方法栈:和虚拟机栈非常相似,虚拟机栈为虚拟机执行java方法(字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。

Java:是java虚拟机所管理的内存中最大的一块,在虚拟机启动时创建,目的是存放对象实例,所有的对象实例以及数组都要在堆上分配,java堆也称为GC堆,从内存回收的角度来看,现在收集器基本都采用分代收集算法,所以java堆中还可以细分为:新生代和老年代,在细致一点的有Eden空间,From Survivor空间,To Survivor空间。从内存分配的角度看,线程共享的java堆中可能划分出多个线程私有的分配缓冲区,java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,在实现时,既可以实现成固定大小,也可以是可扩展的,主流的虚拟机都是按照可扩展来实现的,如果堆中没有内存完成实例分配,并且堆也无法在扩展时,将会抛出OutOfMemoryError异常

方法区(java8.0后变为元数据区,存放在本地内存中,1.7之前存放在虚拟机中):用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据,规范把方法区描述为堆的一个逻辑部分,有个别名叫Non-Heap非堆),当方法区无法满足内存分配需求是,将抛出OutOfMemoryError异常

运行时常量池:方法区的一部分Class文件中除了有类的版本,字段,方法,接口,等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中,运行时常量池具备动态性运行期间也可能将新的常量放入池中,比如String类的intern()方法。常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常

直接内存:并不是虚拟机运行时数据区的一部分,也不是规范中定义的内存区域。会受到本机总内存大小以及处理器寻址空间的限制,各个内存区域总和大于物理内存限制会导致动态扩展时出现OutOfMemoryError异常

对象的创建

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,假设java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”,如果java堆中的内存并不是规整的,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”采用空闲列表方式在并发情况下也并不是线程安全的,解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性,另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步保证了对象的实例字段在java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。接下来,虚拟机要对对象进行必要的设置,工作完成后,执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来

对象的内存布局

对象在内存中存储的布局可以分为3块区域:对象头,实例数据对齐填充.

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等.对象头的另一部分是类型指针,对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小.

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容.

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用.

对象的访问定位

引用应该通过何种方式去定位,访问堆中的对象的具体位置,目前主流的访问方式有使用句柄直接指针两种

如果使用句柄访问的话,那么java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息.

如果使用直接指针访问,那么java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址.

两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的时稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,reference本身不需要修改.

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销.

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

GC需要完成的3件事情:

哪些内存需要回收?

什么时候回收?

如何回收?

程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭,这几个区域不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就随着回收了,java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存.

引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,任何时刻计数器为0的对象就是不可能再被使用的.主流的java虚拟机没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题.

可达性分析算法:这个算法的基本思路就是通过一系列的称为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的.

java语言中,可作为GC Roots的对象包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的的对象
  4. 本地方法栈JNI(即一般说的Native方法)引用的对象

引用

判定对象是否存活与引用有关,如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用.引用可分为强引用,软引用,弱引用和虚引用,4中引用强度依次逐渐减弱.

强引用:指在程序代码之中普遍存在的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象.

软引用:用来描述一些还有用但并非必需的对象.对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常.SoftReference类来实现软引用

弱引用:用来描述非必需对象,强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前.当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,WeakReference类来实现弱引用

虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知.PhantomReference类来实现虚引用.

死亡

要真正宣告一个对象死亡,至少要经历两次标记过程,如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法.当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行”.

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的,低优先级的Finalizer线程去执行它,这里所谓的执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束.finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移除出即将回收的集合,如果对象这时候还没有逃脱,那基本上它就真的被回收了

回收方法区

方法区(HotSpot虚拟机中的永久代)中进行垃圾收集的性价比一般比较低,永久代的垃圾收集主要回收两部分内容:废弃常量无用的类

垃圾收集算法

标记-清除算法

标记-清除算法分为标记清除两个阶段,首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象.它的不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.

复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块.当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉,实现简单,运行高效,这种算法的代价是将内存缩小为原来的一半.现在的商业虚拟机都采用这种收集算法来回收新生代,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor.当回收时,EdenSurvivor中还存活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间,HotSpot虚拟机默认EdenSurvivor的大小比例是8:1.Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保

标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,所以老年代一般不能直接选用这种算法.标记-整理算法的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存.

分代收集算法

分代收集算法根据对象存活周期的不同将内存划分为几块,一般是把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法,新生代选用复制算法,老年代使用标记-清理或者标记-整理算法来进行回收.

 

HotSpot的算法实现

枚举根节点

安全点

安全区域

垃圾收集器

 

内存分配与回收策略

Java计数体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题,给对象分配内存以及回收分配给对象的内存

对象的内存分配,往大方向将,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配.少数情况下也可能会直接分配在老年代中.

新生代GC(Minor GC)指发生在新生代的垃圾收集动作,Minor GC非常频繁,一般回收速度也比较快.

老年代GC(Major GC/Full GC)指发生在老年代的GC,Major GC的速度一般会比Minor GC10倍以上.

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

大对象直接进入老年代:所谓的大对象是指,需要大量连续内存空间的java对象,最典型的大对象就是那种很长的字符串以及数组,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置他们.默认超过3MB的对象都会直接在老年代进行分配

长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认为15),就将会被晋升到老年代中.

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

空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的.如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC.

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

 

调优案例分析与实战

 

类文件结构

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在.当遇到占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储

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

无符号数属于基本的数据类型,u1,u2,u4,u8来分别代表1个字节,2个字节,4四个字节和8个字节的无符号数,无符号数可以用来描述数字,索引引用,数量值或者按照UTF-8编码构成字符串值.

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以”info”结尾.表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合.

魔数与Class文件的版本

每个Class文件的4个字节称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件.很多文件存储标准中都使用魔数来进行身份识别,如图片格式gif或者jpeg等在文件头中都存有魔数.(基于安全考虑)Class魔数的值为:0xCAFEBABE(咖啡宝贝?)

紧接着魔数的4个字节存储的时Class文件的版本号:5和第6个字节是次版本号,7和第8个字节是主版本号.

紧接着主次版本号之后的时常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目,由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值,这个容量计数是从1开始,而不是从0开始的.设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达不引用任何一个常量池项目的含义,这种情况就可以把索引值置为0来表示.Class文件结构中只有常量池的容量计数是从1开始的.

常量池中主要存放两大类常量:字面量和符号引用

字面量比较接近于java语言层面的常量概念,如文本字符串,声明为final的常量值等.

符号引用则属于编译原理方面的概念,包括了下面三类常量:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

Java代码在进行javac编译的时候,并不像CC++那样有连接这一步骤,而是在虚拟机加载Class文件的时候进行动态连接.Class文件中不会保存各个方法,字段的最终内存布局信息,因此这些字段,方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用.当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析,翻译到具体的内存地址之中.

常量池中每一项常量都是一个表,14种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位,代表当前这个常量属于哪种常量类型

Javap:专门用于分析Class文件字节码的工具

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final.访问标志中一共有16个标志位可以使用,当前只定义了其中8.

类索引,父类索引接口索引集合都按顺序排列在访问标志之后,类索引,父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系,类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于java语言不允许多重继承,所以父类索引只有一个,因此除了java.lang.Object,所有java类的父类索引都不为0.接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中.对于接口索引集合,入口的第一项----u2类型的数据为接口计数器,表示索引表的容量,如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节

字段表用于描述接口或者类中声明的变量.字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量,字段可以包括的信息有:字段的作用域,是实例变量还是类变量,可变性,并发可见性.可否被序列化,字段数据类型,字段名称.这些信息,各个修饰符都是布尔值.

.

.

.

.

虚拟机类加载机制

 

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

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

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载,验证,准备,解析,初始化,使用,卸载7个阶段.其中验证,准备,解析3个部分统称为连接.

JVM规范规定了有且只有5种情况必须立即对类进行初始化

  1. 遇到new,getstatic,putstaticinvokestatic4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化.生存这4条指令的最常见的java场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(final修饰,已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候.
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化.
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化.

5种场景中的行为称为对一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用

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

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

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

接口的加载过程与类加载过程稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,而接口中不能使用static{}语句块,但编译器仍然会为接口生成<clinit>()类构造器,用于初始化接口中定义的成员变量

方法构造器<init>

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化.

类加载的过程

加载

在加载阶段,虚拟机需要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

数组类本身不通过类加载器创建,它是由java虚拟机直接创建的.但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建.

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全.

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

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

元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求

字节码验证:通过数据流和控制流分析,确定程序语义时合法的,符合逻辑的.

符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段-----解析阶段中发生.符号引用验证可以看做是对类自身以外的信息(常量池的各种符号引用)进行匹配性校验.

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的,但不是一定必要的阶段.如果所运行的全部代码都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间.

准备

准备阶段是正式为类变量分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配,类变量在方法区中,实例变量将会在对象实例化时随着对象一起分配在java堆中.

如果使用了final,那么在准备阶段虚拟机就会根据程序员定义的值进行赋值.

解析

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

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可.

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

解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行.

 

类或接口的解析

字段解析

类方法解析

接口方法解析

 

初始化

到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码),在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其他资源,初始化阶段是执行类构造器<clinit>()方法的过程.

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

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕.因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object.

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作.

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产<clinit>()方法.

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法.但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法.只有当父接口中定义的变量使用时,父接口才会初始化.另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法.

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁,同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕.如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞.

同一个类加载器下,一个类型只会初始化一次.

类加载器

虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类.实现这个动作的代码模块称为类加载器”.

类与类加载器

类加载器只用于实现类的加载动作.对于任意一个类,都需要由加载它的类加载器和这个类一同确立其在java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间.通俗来讲:比较两个类是否相等”,只有在这两个类是由同一个类加载器的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只有加载它们的类加载器不同,那这两个类就必定不相等.

双亲委派模型

java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader.

绝大部分java程序都会使用到一下3种系统提供的类加载器.

启动类加载器(无父类):这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机所识别的类库加载到虚拟机内存中.启动类加载器无法被java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用null代替即可.

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

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

自定义类加载器(父类必定为应用程序类加载器)

类加载器双亲委派模型.

类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父加载器的代码.

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载.

使用双亲委派模型的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系.

双亲委派模型对于保证java程序的稳定运行很重要,实现双亲委派的代码都集中在java.lang.ClassLoaderloadClass()方法之中:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器.如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载.

 

虚拟机字节码执行引擎

执行引擎输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果.

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的栈元素.栈存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息.每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程.一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响.对于执行引擎来说,在活动线程中,栈顶的栈帧称为当前栈帧,与这个栈帧相关联的方法称为当前方法.执行引擎运行的所有字节码指令都只针对当前栈帧进行操作.

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,编译时就确定了该方法所需要分配的局部变量表的最大容量.虚拟机通过索引定位的方式使用布局变量表.方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的.如果执行的是实例方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字”this”来访问到这个隐含的参数.

方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对于的Slot就可以交给其他变量使用.

不应当对赋null值的操作有过多的依赖,经过JIT编译器后,null值的操作在经过JIT编译优化后就会被消除掉,这时候将变量设置为null就是没有意义的.

局部变量表不存在准备阶段”.局部变量定义了但并没有赋初始值是不能使用的.

操作数栈

操作数栈也常称为操作栈,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中,操作数栈元素可以是任意的java数据类型

操作数栈优化处理:令两个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递.

Java虚拟机的解释执行引擎称为基于栈的执行引擎”,其中所指的栈就是操作数栈.

动态连接

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

常量池中指向方法的符号引用,一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化成为静态解析,另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接.

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法.第一种方式是正常完成出口,另外一种退出方式是异常完成出口.无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,而方法异常退出时,返回地址是要通过异常处理器表来确定的.栈帧中一般不会保存这部分信息.方法退出的过程实际上就等同于把当前栈帧出栈.

方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址.需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用.

静态解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,前提是方法必须编译器可知,运行期不可变”,满足要求的方法主要有:静态方法和私有方法以及final方法,前者与类型直接关联,后者在外部不可访问,它们都不可能被重写,因此他们都适合在类加载阶段进行解析.

静态调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用.而分派调用可能是静态的也可能是动态的.

静态分派

左边的称为变量的静态类型(外观类型),右边的称为变量的实际类型.静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅发生在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定.

编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的.

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派.静态分派的典型应用是方法重载,静态分派发生在编译阶段.因此确定静态分派的动作实际上不是由虚拟机来执行的.

静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的.

动态分派

在运行期根据实际类型确定方法执行版本的分派过程称为动态分派.

单分派与多分派

方法的接受者与方法的参数称为方法的宗量,单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择.

Java语言是一门静态多分派,动态单分派的语言.

虚拟机动态分派的实现

动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索.最常用的稳定优化手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能.

虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的.如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址.

虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存和守护内联两种非稳定的激进优化手段来获得更高的性能.

程序编译与代码优化

早期(编译期)优化

前端编译器把*.java文件转变成*.class文件.

后端运行期编译器(JIT)把字节码转变成机器码.

静态提前编译器直接把*.java文件编译成本地机器代码.

虚拟机设计团队把针对性能的优化集中到了后端的即时编译器中,相当多新生的java语法特性,都是靠编译器的语法糖来实现,而不是依赖虚拟机的底层改进来支持.

Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对程序编码来说关系更加密切.

编译过程大致可以分为3个过程:

  1. 解析与填充符号表过程.
  2. 插入式注解处理器的注解处理过程.
  3. 分析与字节码生成过程.

解析与填充符号表

词法分析将源代码的字符流转变为标记(Token)集合.

语法分析根据Token序列构造抽象语法树

完成了语法分析和词法分析后,接着就是填充符号表.符号表是由一组符号地址和符号信息构成的表格.符号表中所登记的信息在编译的不同阶段都要用到.在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码.在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据.

注解处理器

注解与普通的java代码一样,是在运行期间发挥作用的.

语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的.

语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查.

解语法糖

语法糖也称糖衣语法,指在计算机语言中添加某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用.使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会.

Java中常用的语法糖主要有:泛型,变长参数,自动装箱/自动拆箱等.虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖.

字节码生成是javac编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息转化为字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作.如实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段添加到语法树中的(默认构造函数在填充符号表阶段就已经完成)还有一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBufferStringBuilderappend()操作等.

泛型与类型擦除

泛型的本质是参数化类型,参数化类型可以用在类,接口和方法的创建中,分别称为泛型类,泛型接口和泛型方法.

基于类型膨胀方法实现的泛型称为真实泛型.

Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(也称裸类型),并且在相应的地方插入了强制类型转换代码

Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型.

泛型用于提升语义准确性,当泛型遇见重载,擦除动作导致两个方法的特征签名变得一模一样.

方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择.

Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存.也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的.

擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据.

自动装箱,拆箱与遍历循环

自动装箱,拆箱与遍历循环是java语言里使用得最多的语法糖.遍历循环需要被遍历的类实现Iterable接口.

包装类的”==”运算在不遇到算数运算的情况下不会自动拆箱,它们的equals()方法不处理数据转型.

条件编译

Java语言也可以进行条件编译,方法就是使用条件为常量的if语句,这也是java语言的语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉.

Java语言中还有不少其他的语法糖,如内部类,枚举类,断言语句,对枚举和字符串的switch支持,try语句中定义和关闭资源等

晚期(运行期)优化.

JIT的部分优化技术:

方法内联,冗余访问消除,复写传播,无用代码消除.

语言无关的经典优化技术之一:公共子表达式消除.

语言相关的金店优化技术之一:数组范围检查消除.

最重要的优化技术之一:方法内联.

最前沿的优化技术之一:逃逸分析.

公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式.只需要直接使用前面计算过的表达式结果代替E就可以了.如果这种优化仅限于程序基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,就称为全局公共子表达式消除.

数组边界检查消除:访问数组元素的时候系统会自动进行上下界的范围检查,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担.

Java中空指针检查和算数运算中除数为零的检查都采用了隐式异常处理.

异常处理器抛出异常必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判断检查慢.

与语言相关的消除操作还有:自动装箱消除,安全点消除,消除反射.

方法内联:可以消除方法的调用成本,为其他优化手段建立良好的基础.工作原理------把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用而已.

内联时,如果是非虚方法,那么直接进行内联就可以了.如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个逃生门”,称为守护内联.

内联缓存:是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接受者版本,如果以后进来的每次调用的方法接受者版本都是一样的,那这个内联还可以一直用下去.如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派.

逃逸分析:是为其他优化手段提供依据的分析技术,逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸.甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程之中访问的实例变量,称为线程逃逸.

如果能证明一个对象不会逃逸方法或线程之外,则可能为这个变量进行一些高效的优化:栈上分配,同步消除,标量替换.

栈上分配:如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁.在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多.

同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉.

标量替换:标量是指一个数据已经无法在分解成更小的数据来表示了,java虚拟机中的原始数据类型就可以称为标量,如果一个数据可以继续分解,那它就称作聚合量,java中的对象就是最典型的聚合量.如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫标量替换.如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替.将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步优化手段创建条件.

逃逸分析这项优化尚未足够成熟,仍有很大的改进余地,主要原因是不能保证逃逸分析的性能收益必定高于它的消耗.

栈上分配在HotSpot中暂时还没有做这项优化.

高效并发

Amdahl定律通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力

摩尔定律则用于描述处理器晶体管与运行效率之间的发展关系.

衡量一个服务性能的高低好坏,每秒事务处理数TPS是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,TPS值与程序的并发能力又有非常密切的关系.

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了一个新的问题:缓存一致性.

除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证.与处理器的乱序执行优化类似,java虚拟机的即时编译器中也有类似的指令重排序优化.

Java内存模型的主要目标是定义程序中各个变量的访问规则,此处的变量与java变成中所说的变量有所区别,它包括了实力字段,静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的.不会被共享,自然就不会存在竞争问题.

Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量.不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成.

如果局部变量是一个reference类型,它引用的对象在java堆中可被各个线程共享,但是reference本身在java栈的局部变量表中,它是线程私有的.

Java内存模型中定义了以下8种操作来完成主内存与工作内存之间具体的交互协议:

  1. Lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态.
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定.
  3. read():作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用.
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中.
  5. use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作.
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作.
  7. store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用.
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中.

先行发生原则用来确定一个访问在并发环境下是否安全.

关键字volatilejava虚拟机提供的最轻量级的同步机制.java内存模型对volatile专门定义了一些特殊的访问规则:第一是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的.第二是禁止指令重排序优化.

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

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值.
  2. 变量不需要与其他状态变量共同参与不变约束.

DCL双锁检测

我们在volatile与锁之中选择的唯一依据仅仅是volatile的语义是否能满足场景的需求.

原子性:java内存模型来直接保证的原子性变量操作包括read,load,assign,use,storewrite.我们大致可以认为基本数据类型的访问读写是具备原子性的.synchronzed块之间的操作也具备原子性.

可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改.除了volatile之外,java还有两个关键字能实现可见性,synchronizedfinal.final关键字的可见性是指:final修饰的字段在构造器中一旦初始化完成,并且构造器没有把”this”的引用传递出去,那在其他线程中就能看见final字段的值.

有序性:如果在本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的.

先行发生原则是判断数据是否存在竞争,线程是否安全的主要依据.先行发生是java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,”影响包括了修改内存中共享变量的值,发送了消息,调用了方法等.

Java内存模型下一些天然的先行发生关系:

程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作.

管程锁定规则:一个unlock操作先行发生于后面(时间上的)对同一个锁的lock操作.

volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作.

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

线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测.

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生.

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

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

实现线程主要有3种方式:使用内核线程实现,使用用户线程实现和使用用户线程加轻量级进程混合实现.

内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上.每个内核线程可以视为内核的一个分身.这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核.

程序一般不会直接去使用内核线程,而是使用内核线程的一种高级接口-----轻量级进程,轻量级进程就是我们通常意义上所讲的线程.由于每个轻量级进程都由一个内核线程支持,所以各种线程操作,如创建,析构及同步,都需要进行系统调用,需要在用户态和内核态中来回切换,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间)因此一个系统支持轻量级进程的数量是有限的.

从广义上件,一个线程只要不是内核线程,就可以认为是用户线程,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制.

狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现.用户线程的建立,同步,销毁和调度完全在用户态中完成,不需要内核的帮助.操作可以是非常快速且低消耗的.也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的.使用用户线程实现的程序一般都比较复杂.java放弃使用它.

还有一种将内核线程与用户线程一起使用的实现方式.这种实现下,既存在用户线程,也存在轻量级进程.用户线程还是完全建立在用户空间中,因此用户线程的创建,切换,析构等操作依然廉价,并且可以支持大规模的用户线程并发.

Java语言定义了5种线程状态,在任意时间点,一个线程只能有且只有其中的一种状态.

新建:创建后尚未启动的线程处于这种状态.

运行:包括了操作系统线程状态中的RunningReady,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间.

无限期等待:处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒.以下方法会让线程陷入无限期的等待状态:

  1. 没有设置Timeout参数的Object.wait()方法.
  2. 没有设置Timeout参数的Thread.join()方法.
  3. LockSupport.park()方法.

限期等待:处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒.在一定时间之后它们会由系统自动唤醒,以下方法会让线程进入限期等待状态:

Thread.sleep()方法.

设置了Timeout参数的Object.wait()方法.

设置了Timeout参数的Thread.join()方法.

LockSupport.parkNanos()方法.

LockSupport.parkUntil()方法.

阻塞:线程被阻塞了,”阻塞状态等待状态的区别是:”阻塞状态在等待着获取到一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生;等待状态则是在等待一段时间,或者唤醒动作的发生.在程序等待进入同步区域的时候,线程将进入这种状态.

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

线程安全与锁优化

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的.

不可变的对象一定是线程安全的.只要一个不可变的对象被正确的构建出来(没有发生this引用逃逸的情况)那其外部的可见状态用于也不会改变.”不可变带来的安全性是最简单和最纯粹的.如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变.如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行.

javaAPI中标注自己是线程安全的类,大多数都不是绝对的线程安全.如果不在方法调用端做额外的同步措施的话,代码仍然是不安全的.

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

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

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

互斥同步:同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用.而互斥是实现同步的一种手段,临界区,互斥量和信号量都是主要的互斥实现方式.

synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题.同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入.要阻塞或唤醒一个线程,都需要从用户态转换到核心态中.

还可以使用重入锁来实现同步,它还增加了一些高级功能:等待可中断,可实现公平锁,以及锁可以绑定多个条件.

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

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;synchronized中的锁是非公平的,重入锁默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁.

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

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步.从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都要进行加锁,用户态核心态转换,维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作.

随着硬件指令集的发展,有了基于冲突检测的乐观并发策略:就是先操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就在采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步.

要保证线程安全,并不是一定就要进行同步,两者没有因果关系.同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的.主要有两类:

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

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

Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为易变的”;如果一个变量要被某个线程独享.可以通过java.lang.ThreadLocal类来实现线程本地存储的功能.

锁优化技术有:适应性自旋,锁消除,锁粗化,轻量级锁偏向锁.这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题.从而提高程序的执行效率.

在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得.如果物理机器有一个以上的处理器.能让两个或以上的线程同时并行执行.我们就可以让后面请求锁的那个线程稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁.为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁.

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,如果锁被占用的时间很短,那么自旋的线程只会白白消耗处理器资源.会带来性能上的浪费.因此,自旋等待的时间必须要有一定的限度.如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统等待方式去挂起线程了.自旋次数的默认值是10.

JDK1.6中引入了自适应的自旋锁.自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定.

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除.锁消除的主要判定依据来源于逃逸分析的数据支持.

由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此javac编译器会对String连接做自动优化.JDK1.5之后,会转化为StringBuilder对象的连续append()操作.

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小---只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能的变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁.

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体重的,那即时没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗.如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部.这就是锁粗化

轻量级锁:JDK1.6之中加入的新型锁机制,它名字中的轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为重量级.轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗.

HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行数据,它是实现轻量级锁和偏向锁的关键.另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度........

偏向锁:偏向锁是JDK1.6引入的一项锁优化,它的目的是消除在无竞争情况下的同步原语,进一步提高程序的运行性能.这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步.

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值