深入理解Java虚拟机

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

一、运行时数据区域

1、程序计数器

线程私有,一块较小的内存空间,可以看成是当前线程字节码执行的行号。唯一一个不会有OutOfMemoryError情况出现

2、Java虚拟机栈

线程私有,线程运行时的java内存模型,存储局部变量表、操作数栈、动态链接、方法返回地址。局部变量表中存放编译期可知的各种基本数据类型、对象引用。当线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlow异常;如果扩展时无法申请更多内存,将抛出OutOfMemoryError。

3、本地方法栈

线程私有,本地方法运行时内存模型,与Java虚拟机栈类似,HotSpot将两者合二为一。

4、堆

线程共享,对象分配的地方,几乎所有的Java对象都分配在堆上,垃圾回收的主要区域。从内存回收的角度可以分为新生代和老年代,新生代又可细分为Eden区、From Survivor、To Survivor。从内存分配的角度,可以将堆分为多个线程私有的分配缓冲区(TLAB)。

5、方法区

线程共享,存储类信息、静态变量、常量、即时编译器编译后的代码等。

方法区、永久代、元空间的关系

方法区是Java虚拟机规范中的概念,规定了这个区域应该存储哪些数据,是一个逻辑概念,没有规定需要怎样实现,HotSpot在jdk1.8之前都是用永久代来实现方法区,而从1.8开始移除了永久代,将永久代里的部分数据放大本地直接内存中存储,而将其他一部分数据移到堆中存储,例如字符串常量池。在本地内存中的那部分区域就叫元空间。

常量池

在Class文件里有一项信息叫常量池,存放着编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池,运行时常量池也会在运行时添加对象,比如string的intern方法会将字符串常量添加进常量池。

二、HotSpot对象探秘

1、对象创建

当虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,则先完成类加载。在类加载检查通过后,虚拟机为对象分配内存。对象所需的内存在类加载之后就可完全确定。分配内存之后,需要执行<init>方法完成对象初始化。

1.1 对象怎样分配

  • 指针碰撞:假设java内存堆是绝对规整的,所有已分配的内存在一块,未分配的内存在一块,中间由一个指针标识界限,为新对象分配内存只需要移动指针就可以了。
  • 空闲列表:如果java内存堆并不是规整的,需要维护一个列表,记录哪些内存是可用的,在分配的时候从列表中选取一块足够大的空间划分给对象实例,并更新列表。

使用哪种方法取决于java堆内存是否规整,堆内存是否规整又取决于所使用的垃圾回收器是否带有压缩功能。在使用serial、parnew等带compact的回收器时使用指针碰撞的方式。

1.2 怎样保证分配内存时的线程安全问题

  • CAS和重试
  • TLAB 把内存分配的动作按照线程划分到不同的空间之中,即每个线程预先分配一小块内存,称为本地线程分配缓冲区(TLAB),内存分配时是线程独占的,当分配完成后线程共享。可以通过参数+/-UseTLAB设定是否使用TLAB分配。

2、对象内存布局

对象头、实例数据、对齐填充

  • 对象头包括Mark World和指向类元数据的指针,该指针可有可无,与对象访问定位的方式有关。Mark World内容如下:2bits用于存储锁标志位,1bits固定为0,其他位根据锁标志位不同存储的内容也不同。
存储内容 标志位 状态
对象hash码、分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 10 可偏向
  • 实例数据:包括父类和子类的实例数据,占有相同字节数据的类型的数据存放在一起,在此基础上,父类数据在子类之前。
  • 对齐填充:HotSpot中规定对象的起始地址是8字节的整数倍,也就要求每个对象占用的内存大小是8字节的整数倍,所以才有对齐填充。

3、对象访问定位

  • 句柄:栈上的引用是句柄池中的一个句柄的地址,句柄存储对象的地址和对象类信息的地址,需要经过两次寻址才能找到对象,所以速度相对慢一些,还需要维护句柄池。但是当对象地址改变时只需要修改句柄里的地址信息,所有引用该对象的地方都不需要修改。
  • 直接指针:栈上的引用就是对象的内存地址,只需要一次寻址就可以找到对象,速度相对快一些,但是当对象地址改变时需要将所有引用都修改。

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

三、判断对象是否已死

两种方法:

  • 引用计数法:为对象记录一个引用计数器,每当有其他对象引用该对象时,引用计数器加1,当其他对象不再引用该对象时,计数器减1.存在的问题是无法解决循环引用问题,所以Java中并不用该方法。
  • 可达性分析法:以一系列被称为“GC Root”的对象为起点,从这些节点开始向下搜索,做过的路径称为引用链,不再任何一条GC Root的引用链上的对象即是不可达的对象,可以判定该对象已死。

1、哪些对象可以是GC Root

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

2、引用分类

  • 强引用:普通的对象引用,例如

    Object o = new Object();
    复制代码
  • 软引用:用来描述一些还有用但是非必需的对象。sr就是软引用。软引用关联的对象在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

    SoftReference<Object> sr = new SoftReference<>(o);
    复制代码
  • 弱引用:也是用来描述非必需对象,但是强度比软引用要更弱一些。虚引用关联的对象只能存活到下次垃圾收集之前。也就是只要发生垃圾收集,虚引用所关联的对象就会被当做垃圾回收。

    WeakReference<Object> wr = new WeakReference<>(o);
    复制代码
  • 虚引用:最弱的引用关系,无法通过虚引用去的对象实例,为对象设置虚引用的唯一目的就是在这个对象被收集器回收时收到一个系统通知。

    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    PhantomReference<Object> pr = new PhantomReference<>(cacheResult, queue); //虚引用需要关联一个ReferenceQueue
    复制代码

3、对象是否真的已死

当一个对象经过可达性分析后被标记为不可达,是否就一定会被垃圾收集器回收?实际上还需需要经过一次筛选,筛选的条件是该对象是否有必要执行finalize()方法。如果该对象没有实现finalize()方法或者该对象的finalize()方法已经被执行过,则视为该对象没有必要执行finalize()方法,垃圾收集器将会回收该对象。

加入该对象有必要执行finalize()方法,则将该对象放到一个叫F-Queue的队列里,并且稍后由一个由虚拟机建立的低优先级的线程 Finalizer去执行,这里的执行只是触发finalize(),并不会等待该方法执行完毕再执行向下一个对象的finalize()方法,以防在某个finalize()方法中执行缓慢或发生死循环,导致其他对象一直等待。稍后,GC将对F-Queue里的对象进行第二次标记,如果对象在finalize()方法中将自己重新被引用链上的对象所引用,则该对象将被移除“即将回收”的集合,否则该对象会被当成垃圾回收。

4、回收方法区

方法区回收的内容:废弃的常量、无用的类。类需要同时满足以下3个条件才能算是“无用的类”:

  1. 该类的所有实例都已被回收
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以 但不一定 对无用类进行行回收,需要通过参数-Xnoclassgc进行控制。

四、垃圾收集算法

1、标记-清除算法

该算法有两个问题:一是效率问题,标记和清除两个过程效率都不高;二是出现内存碎片。

2、复制算法

将内存分为两部分,一部分使用,另一部分空闲。当进行垃圾回收时将存活的对象依次复制到空闲内存块,清除已使用的内存块。问题在于如果将内存分为两份大小相等的区域,则非常浪费内存空间。所以通常在新生代中将内存分为Eden,S1,S2,因为新生代对象有“朝生暮死”的特点,所以可以将Eden:S1:S2设置为8:1:1(默认设置),每次只在Eden和其中一个S区为对象分配内存,当进行垃圾回收时,将Eden和其中一个S区中存活的对象复制到空闲的S区。

3、标记-整理算法

标记之后,将存活对象都向一端移动,然后直接清除掉端边界以外的内存。

4、分代收集

根据新生代和老年代的特点使用不同的算法。新生代用复制算法,老年代用标记-清除或标记-整理算法。

五、HotSpot的算法实现

1、枚举根节点

可达性分析的第一个就是枚举根节点,如果此时程序还在进行的话,根节点肯定一直变化,所以在枚举根节点这一步是需要停止程序运行的。枚举根节点并不是通过遍历所有执行上下文和全局的引用位置,虚拟机有办法直接得知哪些地方存放着对象引用。在HotSpot中,使用一组称为OopMap的数据结构来达到这个目的。GC只需要找到所有的OopMap就能获得所有的GC Root对象。OopMap在什么时候生成呢?答案就是安全点。

2、安全点(Safe Point)

虚拟机只会在某些特定位置才会生成OopMap,这些位置称为安全点。安全点的选定是以“是否能让程序长时间执行的特征”为标准选定的。所以当程序运行到具有以下几个功能的指令时才会产生安全点:

  • 方法调用
  • 循环跳转
  • 异常跳转等

有了安全点后,如何在GC发生时让所有线程都跑到最近的安全点并停下来,两种方式:

  • 抢先式中断:在GC发生时,将所有线程中断,如果某个线程中断的地方不在安全电上,让它继续执行,并“跑”到安全点。
  • 主动式中断:当GC需要中断线程时,就简单地设置一个标志,每次线程到达安全点都检查一下这个标志,当标志为真时就主动中断挂起。

目前都是使用主动式中断。

3、安全区域(Safe Region)

安全点解决了枚举根节点的问题,但是安全点需要线程都处于运行状态,假如有的线程处于sleep或者blocked的状态,无法去轮询GC设置的标志,虚拟机也不可能等待它们被唤醒重新进入运行状态之后再尝试GC。这时候就需要安全区域了。 安全区域是指在一段代码片段中,引用关系不会发生改变。 在线程执行到Safe Region时,先标记自己已经进入了Safe Region,那样,当这段时间里jvm要发起GC时,就不用管标识自己进入安全区域状态的线程。在线程要离开安全区域时,先检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,线程继续,否则它必须等待直到收到可以安全离开安全区域的信号。

六、垃圾收集器

两个概念:

  • 并行:多条垃圾收集线程并行工作,用户线程处于等待状态。
  • 并发:垃圾收集线程与用户线程同时执行。

1、Serial

单线程,新生代收集,复制算法,整个过程都会暂停所有工作线程。

2、ParNew

Serial的多线程版本。CMS默认的新生代收集器,也可以通过-XX:+UseParNewGC指定,-XX:ParalleGCThreads可设置线程数。

3、Parallel Scavenge

多线程,新生代收集,复制算法,STW。

该收集器关注点与其他收集器不同,CMS等收集器关注点事尽可能地缩短停顿时间,而Parallel Scavenge的目标是达到一个可控的吞吐量,吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)。停顿时间越短越适合需要与用户交互的程序,高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,适合后台运算而不需要太多交互的任务。

相关参数:

  • -XX:MaxGCPauseMillis 最大垃圾收集停顿时间。收集器将尽可能保证内存回收花费的时间不超过设定值。GC停顿时间缩短是以牺牲吞吐量和新生代空间换取的:系统把新生代调小一些,停顿时间也就会短一些,但是垃圾收集也会发生的更频繁一些。
  • -XX:GCTimeRatio 该参数的值应当是一个大于0小于100的整数。GC时间占比=1/(1+GCTimeRatio)。
  • -XX:+UseAdaptiveSizePolicy 自适应调节策略,如果使用该参数,将不需要设置新生代大小、Eden与Survivor的比例、晋升到老年代对象的大小等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整。

4、Serial Old

单线程,老年代收集,标记-整理算法,整个收集过程都会Stop The World。

两大用途:在jdk1.5之前,与Parallel Scanvenge搭配使用;作为CMS收集器的后备预案,在并发收集发生 Concurrent Mode Failure时使用。

5、Parallel Old

多线程,老年代收集,标记-整理算法,STW。吞吐量优先,作为Parallel Scavenge的搭档使用。

CMS

多线程,老年代收集,标记-清除算法,只在其中两个步骤中Stop The World,整体上可认为是与工作线程并发执行。步骤如下:

  1. 初始标记:STW,只标记出与GC Roots直接相连的对象,速度很快。
  2. 并发标记:不会STW,进行GC Roots Tracing的过程。
  3. 重新标记:STW,修正在并发标记期间因用户线程继续运行而导致的标记产生变动的那一部分对象象的标记记录,在这个阶段停顿时间一般会比初始标记阶段稍长一些,但是远比并发表及时间短。
  4. 并发清除:不会STW,清除垃圾。

CMS的优点:并发收集,低停顿。CMS的缺点如下:

  • CPU资源敏感,默认垃圾回收线程数为(cpu数量+1)/4,所以当cms开始执行时,用户线程会受到影响。
  • 无法处理浮动垃圾,浮动垃圾是指在CMS标记过程之后,用户线程产生的垃圾,CMS在当次收集中无法处理它们,只能等到下一次GC时再清理掉。
  • 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC。由于CMS是并发的垃圾收集器,在CMS进行垃圾回收时用户线程仍在继续运行,依然会有请求内存分配的情况发生,所以CMS不能等到老年代几乎完全被填满之后再进行收集,需要预留一部分空间供并发收集时程序运行使用。参数-XX:CMSInitiatingOccupancyFraction用来设置老年代内存占用比达到多少时开始进行垃圾回收,默认92%。如果在CMS运行期间,预留内存无法满足程序运行的需要,就会出现“Concurrent Mode Failure”失败,这是虚拟机将启动后备预案:临时启用Serial Old重新进行老年代垃圾回收,这样停顿时间就很长。所以需要合理设置参数-XX:CMSInitiatingOccupancyFraction。
  • 产生内存碎片。CMS使用的是标记-清除算法,所以会产生内存碎片。为了解决内存碎片过多导致频繁Full GC的问题,可以通过设置-XX:UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器在顶不住要进行FullGC时开启内存碎片整理过程,当然这样会是停顿时间边长。另一个参数-XX:CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的FullGC之后,跟着来一次带压缩的(默认值为0,标识每次都进行碎片整理)。

G1

G1收集器的特点:

  1. 并发与并行:充分利用多CPU、多核环境下的硬件优势。
  2. 分代收集:独立管理整个堆,但依然会划分老年代新生代。
  3. 空间整合:G1从整理上看是基于标记-整理算法实现的收集器,从局部(Region)来看是基于复制算法。这两种算法都意味着G1运作期间不会产生内存空间碎片。
  4. 可预测的停顿:G1追求低停顿,还能建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。

虚拟机执行子系统

七、虚拟机类加载机制

1、定义

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

2、类的生命周期

加载、验证、准备、解析、初始化、使用和卸载。

类的加载过程必须按照这种顺序按部就班地 开始 ,但是解析阶段则不一定:在某些情况下可以在初始化之后再开始。这里说的是开始的顺序,并不是完成一个阶段才进入下一个阶段,通常都是互相交叉混合地进行。

3、什么时候对一个类会进行初始化(或者加载)?

有且只有一下五种情况会触发类的初始化:

  1. 当遇到new,putstatic,getstatic,invokestatic这四条字节码指令时,如果一个类还没有初始化,则先触发其初始化。也就是使用new方法创建对象,访问类的静态字段或静态方法时。
  2. 使用java.lang.reflect包对类进行反射调用时。
  3. 当要初始化一个类时,如果其父类还未被初始化,将先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个执行的主类,该类将在虚拟机启动时被初始化。
  5. 如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokestatic的方法句柄,并且这个方法句柄多对应的类没有进行过初始化,则需要先触发其初始化。

特殊的例子

  1. 通过子类名引用父类的静态变量,实际上只会触发父类的初始化。
  2. 定义并初始化一个类的数组如A[] arr = new A[10],并不会触发一个类的初始化。
  3. 当在一个类A里引用另一个类B里定义的常量,并不会触发另一个类的初始化,因为在编译阶段通过常量传播优化,类B中的常量会被存储到类A的常量池里。

4、类加载的过程

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

    1. 通过一个类的全限定名来获取此类的二进制字节流;
    2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  2. 验证。目的是确保Class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。大概会完成下面四个阶段的校验动作:

    1. 文件格式验证:是否符合Class文件规范
    2. 元数据验证:对字节码描述的信息进行语义分析,实际是验证是否符合java语言规范的要求,比如是否继承了被final修饰的类,字段和方法是否与父类产生矛盾等
    3. 字节码验证:确定程序语义是合法的、符合逻辑的
    4. 符号引验证:符号引用中通过字符串描述的全限定名是否能找到对应的类;在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;符号引用中的类、字段、方法的访问性是否可被当前类访问…………
  3. 准备。正式为类变量分配内存并设置类变量的初始值,通常情况下,初始值即零值,但是 常量 会在准备阶段赋值为被程序定义的值。

  4. 解析。将常量池中的符号引用替换为直接引用的过程。

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

      #3 = Fieldref           #32.#33        // java/lang/System.out:Ljava/io/PrintStream;
      #5 = Methodref          #35.#36        // java/io/PrintStream.println:(Ljava/lang/String;)V
      复制代码
    • 直接引用:可以是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。

5、初始化。执行类构造器方法,该方法由编译器自动收集所有的类变量复制动作和静态代码块中的语句合并产生的,收集顺序由语句在源文件中出现的顺序决定。静态语句块只能访问到定义在静态语句块之前的变量,在它之后的变量,可以在该块中给该变量赋值,但是不能访问。

5、类加载器

对于任一个一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每一个类加载器都有一个独立的类名称空间。通俗的讲,比较两个类是否“相等”(Class对象的equals()方法,instance关键字等),只有在这两个类是由同一个类加载加载的前提下才有意义,也就是才有可能“相等”。

系统提供的类加载器

启动类加载器(又叫根类加载器,Bootstrap ClassLoader)

负责加载<JAVA_HOME>/lib目录中的,或者被-Xbootclasspath参数指定的路径中的,并且是虚拟机识别的(仅按照名字识别)类库加载到虚拟机内存中

扩展类加载器(Extension ClassLoader)

负载加载<JAVA_HOME>/lib/ext目录中的,或者被java.ext.dirs系统变量指定路径中的类库,开发者可以直接使用扩展类加载器。

应用程序类加载器(又叫系统类加载器,Application ClassLoader)

负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序没有自定义过自己的类加载器,一般情况向下这个就是程序中默认的类加载器。

6、双亲委派模型

过程

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

为什么使用双亲委派模型

保证Java程序的稳定运作。 黑客自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍作修改。比如equals函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到JVM中。此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致“病毒代码”被执行。 而有了双亲委派模型,黑客自定义的java.lang.String类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。

怎样破坏双亲委派模型

双亲委派模型并不是一个强制性约束,而是java设计者推荐给开发者的类加载器的实现方式,在一定条件下,为了完成某些操作,可以“破坏”模型。

  1. 重写loadClass方法
  2. 利用线程上下文加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的 setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
  3. 为了实现热插拔,热部署,模块化,意思是添加一个功能或减去一个功能不用重启,只需要把这模块连同类加载器一起换掉就实现了代码的热替换。

程序编译与代码优化

八、晚期(运行期)优化

1、编译对象

晚期优化是依赖即时编译器(JIT)来将字节码翻译成机器码,从而提高执行效率。JIT编译的“热点代码”有两类,即:

  1. 被多次调用的方法
  2. 被多次执行的循环体

一个方法被多次调用,其内部的代码被执行的次数自然多,称为“热点代码”是理所当然的。而编译多次执行的循环体是为了解决方法被调用次数较少,但是循环次数较多的问题,这样循环体里的代码执行的次数较多,也应该被认为是“热点代码”。实际上,对于这两类,编译器编译的对象都是方法,而不是单独的循环体。而第二类的编译又称为栈上替换(On Stack Replacement),简称OSR编译,因为编译发生在方法执行过程中。

2、热点代码检测

热点代码的检测一般有两种方法:

  1. 基于采样的热点探测:虚拟机周期性地检查各个线程的栈顶,如果某个方法经常出现在栈顶,那这个方法就是热点方法。该方法的好处是实现简单、高效,还可以很容易获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易受到线程阻塞或别的外界因素影响而扰乱热点探测。
  2. 基于计数器的热点探测:为没有方法(甚至是代码块)建立一个计数器,统计方法执行次数,当达到一个阈值时,认为该方法为热点方法。这种方法实现较为麻烦,需要为每一个方法维护一个计数器,而且不能直接获取方法的调用关系,但是统计结果相对来说更为精确和严谨。HotSpot虚拟机使用这种方法来进行热点探测。

3、热点探测过程

为每个方法维护两个计数器:方法调用计数器和回边计数器

  1. 方法调用计数器:统计方法调用次数,通过-XX:CompileThreshold设置阈值,当方法调用计数器与回边计数器之和达到该阈值时,触发编译,一般情况下是后台编译,也就是方法会继续以解释的方式执行,直到编译完成之后,如果再有该方法的调用,则会执行编译后的版本,也可以选择等待编译完成之后再执行方法。该计数器统计的是一段时间之内的调用次数,所以如果超过某个时间限度,依然没有达到阈值,则该计数器会减半,这段时间也称为方法统计的半衰期,也可以通过-XX:-UseCounterDecay来关闭使用半衰期。
  1. 回边计数器:统计一个方法中循环的次数,在字节码遇到控制流向后跳转的指令称为“回边”。

4、具有代表性的几项优化技术

  1. 语言无关:公共子表达式消除
  2. 语言相关:数组范围检查消除
  3. 最重要的优化技术之一:方法内联
  4. 最前沿的优化技术之一:逃逸分析

5、逃逸分析

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

如果一个对象不会逃逸到方法或线程之外,则可以对这个变量做一些高效的优化:

  1. 栈上分配:将对象分配在栈上,需搭配标量替换。
  2. 标量替换:标量是指不能再分解成更小的数据来表示了,java虚拟机中的原始数据类型就是标量。相对的,如果一个变量可以继续分解,则称为聚合量。如果把一个java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问叫做标量替换。
  3. 同步消除: 如果确定一个变量不会逃逸出线程,则可以消除对该变量实施的同步措施。

高效并发

九、Java内存模型与线程

1、内存间的相互操作

  1. lock:作用于主内存变量,把一个变量标识为一个线程独占的状态。
  2. unlock:作用于主内存变量,解除锁定状态。
  3. read:作用于主内存变量,把一个变量的值从主内存传输部到线程的工作内存。
  4. load:作用于工作内存的变量,把read操作读取的值放入线程工作内存变量的副本。
  5. use:作用于工作内存的变量,把工作内存的变量的值传递给执行引擎。
  6. assign:作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存中的变量。
  7. store:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存。
  8. write:作用于主内存变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作;如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。java内存模型要求上述两个操作必须按顺序,但不保证连续执行,中间可以插入其他指令。如read a, read b, load b, load a。除此之外,java内存模型还规定了再执行上述8中操作时必须满足以下规则:

  1. 不允许read和load,store和write操作之一单独出席;
  2. 不允许一个线程对齐它的最近的assign操作,即变量在工作内存中发生改变必须同步回主内存;
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据同步回主内存;
  4. 一个新变量在主内存中“诞生”,不允许工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,一个变量在use、store之前,必须先执行过了assign和load操作;
  5. 一个变量在同一时刻只允许一个线程对其进行lock操作;
  6. 如果对一个变量执行了lock操作,那将清空工作内存中此变量的值,在执行引擎使用这个变量前,需要先重新load或assign操作初始化变量的值;
  7. 如果一个变量没有被lock,则不允许对其执行unlock;
  8. 对一个变量unlock之前,必须先把该变量同步回主内存中。

2、volatile特殊规则

volatile变量的两种特性:

  1. 可见性:如果一个线程修改了volatile变量的值,新值对其他线程来说是立即可见的;
  2. 禁止指令重排序优化。

除了volatile之外,java中还有两个关键字能实现可见性,即synchronized和final。synchronized是由“对一个变量的unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的;而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其他线程中就能看见final字段的值。

3、先行发生原则

  1. 程序次序规则:在一个线程中,按照程序代码顺序,书写在前面的操作先行发生与书写在后面的操作。
  2. 管程锁定规则:一个unblock操作先行发生于后面对同一个锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于读操作。
  4. 线程启动规则:Thread对象的start方法先行发生于此线程的每一个动作。
  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到的中断事件的发生。
  7. 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。
  8. 传递性:如果A先行发生于B,B先行发生于C,则A先行发生于C。

4、线程的实现

  1. 使用内核线程实现:程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才有轻量级进程。这种轻量级进程与内核线程1:1的关系称为一对一的线程模型,如下图:
  1. 使用用户线程实现:狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持更大规模的线程数量。这种进程与用户线程1:N的关系成为一对多的线程模型,如下图:
  1. 使用用户线程加轻量级进程混合实现:用户线程还是完全建立在用户控件中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发,而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式下,用户线程与轻量级进程的数量比是不定的,即N:M的关系,如下图:

对于Sun JDK来说,它的Windows版和Linux版都是使用一对一的线程模型实现的,而在Solaris平台中,由于操作系统的线程特性可以同时支持一对一及多对多的线程模型,因为Solaris版的JDK可以通过虚拟机参数明确指定使用哪种线程模型。

5、java线程状态转换

十、线程安全与锁优化

1、线程安全的实现方法

  1. 互斥同步

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

java中实现互斥同步的方法主要有两种,使用synchronized关键字或者ReentrantLock。synchronized关键字经过编译后,会在同步块前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定的和解锁的对象。相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁绑定多个条件。

  1. 非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步又称为阻塞同步。从处理问题的方式上说,互斥同步是一种悲观的并发策略。随着指令集的发展,我们有了另一种选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果有共享数据竞争,产生了冲突,再采取其他措施补救,如不断地重试。这种乐观的并发策略实现都不需要将线程挂起,因此成为非阻塞同步。

将两个步骤原子化的处理器指令有:

1. 测试并设置(Test-and-Set)
2. 获取并增加(Fetch-and-Increment)
3. 交换(Swap)
4. 比较并交换(Compare-and-Swap,即CAS)
5. 加载链接/条件存储(Load-Linked/Store-Conditional,即LL/SC)
复制代码
  1. 无同步方案

如果一个方法本来就不涉及共享数据,那它自然无须任何同步措施,有些代码天生就是线程安全的,例如以下两类:

1. 可重入代码
2. 线程本地存储
复制代码

2、 锁优化

  1. 自旋锁与自适应自旋锁

一个线程在等待锁的时候并不放弃处理器时间,而是执行一个忙循环,等待其他线程释放锁,这就是所谓的自旋锁。自旋是会占用处理器资源的,但是自旋锁有可能节省线程挂起和恢复的开销,如果锁能够很快获取到,那么自旋锁是非常有意义的,如果大部分情况下无法在自旋的情况下获取到锁,那么依然要进行线程的挂起,及之后的恢复,这样比直接挂起和恢复增加了自旋的开销。

自适应自旋是指虚拟机根据之前的经验判断自旋等待能否获取到锁,如果可以,根据以往的经验,需要自旋多少次,一次来调整自旋的次数。如果在获取某个锁的历史记录上,自旋等待从未成功过,那么以后将省略自旋过程,以避免浪费处理器资源,直接挂起等待。

  1. 锁消除

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

  1. 轻量级锁

获取锁:

轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制称为重量级锁。轻量级锁依赖对象的Mark Word实现,在代码进入同步块时,如果此同步对象没有被锁定,那么虚拟机首先在线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(Displaced Mark Word),然后虚拟机将使用CAS尝试将对象的Mark Word更新为指向Lock Record,如果更新成功,那么这个线程就拥有了该对象的锁,并且对象的Mark Word的锁标志位将转变为“00”,表示次对象处于轻量级锁定状态。

如果更新操作失败,虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当先线程已经拥有了这个对象的锁,那就可以直接进入同步块执行,否则说明这个锁对象已经被其他线程抢占了。如果有两个以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。

释放锁:

解锁过程依然是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。如果替换成功,整个同步过程就完成了,如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的一句是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,处理互斥量的开销,还额外发生了CAS操作,因此在有竞争的情况相信爱,轻量级所会比传统的重量级锁更慢。

  1. 偏向锁

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式,同时使用CAS操作把获取这个锁的线程ID记录到对象的Mark Word中,如果CAS成功了,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

当有一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向锁后恢复到未锁定或轻量级锁定的状态,后续同步操作就如上面介绍的轻量级锁那样执行。

偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,也就是说它不一定总是对程序运行有利,如果程序中大多数锁总被多个不同的线程访问,那偏向模式就是多余的。

稀土掘金
我还没有学会写个人说明!
下一篇

2021-Java后端工程师面试指南-(消息队列)

你也可能喜欢

评论已经被关闭。

插入图片