深入java虚拟机-jvm高级特性和实战

Posted 2步之遥

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入java虚拟机-jvm高级特性和实战相关的知识,希望对你有一定的参考价值。

第一部分 走近java

第一章 java技术体系

  官方所定义的java技术体系

  • java程序设计语言
  • 各硬件平台上的java虚拟机
  • Class文件格式
  • java api类库
  • 来自商业机构和开源社区的第三方java类库

  我们可以把java程序设计语言、java虚拟机、java api类库这三部分统称为JDK,是用于支持java程序开发的
最小环境把java api类库总的javaSE api子集和java虚拟机统称为JRE,是支持java程序运行的标准环境。

第二部分 自动内存管理机制

第二章 内存区域与溢出异常

2.1 运行时数据区域

技术分享图片

  • 程序计数器
      程序计数器( Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现) 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳 转、异常处理、线程恢复等基础功能都需**每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
      如果正在执行的是 Native方法,这个计数器值则为空( Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutofMemory Error情况的区域。

  • Java虚拟机栈
      java虚拟机( Java Virtual Machine Stacks)也是线程私有的。它的生命周期与线程相同。虚拟机栈
    描述的是Java方法执行的内存模型;每个方法在执行的同部分自动内存管理机制时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,局部变量表**存放了编译期可知的各种基本数据类型( boolean、byte、char、 short、int、loat、long、 double)、对象引用( reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和 double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
      在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflow Error异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemory Error异常。

  • 本地方法栈
      本地方法栈NativeMethodStack与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如SunHotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常

  • java堆
      Java堆(Java Heap)是Java虚拟机所管理的内存中最大的块Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是: 所有的对象实例以及数组都要在堆上分配,但是随着J编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。
      Jav堆是垃圾收集器管理的主要区域,因此很多时候也被称做"GC堆",据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,既可以实现成固定大小的, 也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-XmxXms控制)如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemory Error

  • 方法区
      方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与Java堆区分开来。

  • 运行时常量池
      运行时常量池( Runtime Constant pool)是方法区的一部分。 Class件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。当常量池无法再申请到内存时,也会抛出OutOfMemory Error

  • 直接内存
      直接内存( Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK14中新加入了NIO(New Input/Output)类,引人了一种基于通道( Channel )与缓冲区( Buffer )的IO方式,它可以使用 Native 函数库直接分配堆外内存,然后通过存储在Java堆中的DirectByte Buffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和 Native堆中来回复制数据。但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理和操作系统级限制)从而导致动态扩展时出现 OutOfMemory Error 异常。

2.2 hostpot虚拟机对象探秘

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

    • 分配内存
        接下来将为新生对象分配内存,对象所需内存在类加载完毕之后就可以完全确定,为对象分配内存空间的任务等同于把一块确定的大小的内存从java堆中划分出来。
        假设Java堆中内存是绝对规整的,所有用过的内存放在一遍,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针指向空闲空间那边挪动一段与对象大小相等的距离,这个分配方式叫做“指针碰撞”
        如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式成为“空闲列表”
        选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
        在分配内存的时候会出现并发的问题,比如在给A对象分配内存的时候,指针还没有来得及修改,对象B又同时使用了原来的指针进行了内存的分片。
        有两个解决方案:
         1、对分配的内存进行同步处理:CAS配上失败重试的方式保证更新操作的原子性
         2、把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中分配一块小内存,称为本地缓冲区,那个线程需要分配内存,就需要在本地缓冲区上进行,只有当缓冲区用完并分配新的缓冲区的时候,才需要同步锁定
    • init
        虚拟机将分配的内存空间都初始化为零值(不包括对象头),这一操作保证了实例对象字段在java代码中可以不赋值就可以直接使用。最后,执行new指令后会接着执行Init方法,按程序的意愿进行初始化,这样一个对象才算完全产生出来。  

  • 对象的内存布局
      在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
    对象头包括两部分:
      a) 储存对象自身的运行时数据,如哈希码、GC分带年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳

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

      实例数据:
      是对象正常储存的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。

    对齐填充:
      不是必然存在的,仅仅是起到占位符的作用。对象的大小必须是8字节的整数倍,而对象头刚好是8字节的整数倍(1倍或者2倍),当实例数据没有对齐的时候,就需要通过对齐填充来补全

  • 对象的访问定位
    • 使用句柄访问
        Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址
        优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改

      技术分享图片

    • 使用直接指针访问
        Java堆对象的布局就必须考虑如何访问类型数据的相关信息,而refreence中存储的直接就是对象的地址。
        优势:速度更快,节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本

      技术分享图片

2.3 OutOfMemoryError 异常(OOM)

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

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

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

  • 虚拟机栈和本地方法栈溢出
      对于HotSpot来说,不区分虚拟机栈和本地方法栈,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
      如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError
      如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常

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

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

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

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

    Intern():
      JDK1.6 intern方法会把首次遇到的字符串实例复制到永久代,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是一个引用
      JDK1.7 intern()方法的实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个

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

    3.1 对象是否存活

  • 引用计数算法
      给对象添加一个应用计数器,每当一个地方引用,计数器加一,当引用失效计数器减一。任何时刻计数器为零对象就是不可能再被引用,但是主流的java虚拟机没有选用引用计数算法来管理内存,注意原因是问题它很难解决对象之间互相循环引用的问题。

  • 可达性分析算法
      在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“ GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链( Reference Chain),当一个对象到 GC Roots没有任何引用链相连时,则证明此对象是不可用的。

  • 是否存活
      即使在可达性分析算法中不可达的对象,它们也只是暂时处于“缓刑”阶段,要真正宣告一个对象被销毁至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖该方法或者该方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为有必要执行该方法,那这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束这样做的原因是,如果一个对象在 finalized方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致 F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize方法是对象逃脱销毁命运的最后一次机会,稍后GC将对F- Queue 中的对象进行第二次小规模的标记,如果对象要在finalized中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。从代码清单中我们可以看到一个对象的finalize被执行,但是它仍然可以存活。
    任何一个对象的 finalize0方法都只会被系统自动调用一次
    finalize运行的代价昂贵,不确定性大,无法保证各个对象的调用顺序,它能做的所有工作,使用try-finally或者其它方式都可以做的更好

      public class FinalizeEscapseGC {
    
      public static FinalizeEscapseGC SAVE_HOOK=null;
    
      public void isAlive(){
        System.out.println("yes, i am still alive");
       }
    
       @Override
       protected void finalize() throws Throwable {
          super.finalize();
          System.out.println("finalize method executed!");
          FinalizeEscapseGC.SAVE_HOOK = this;
       }
    
      public static void main(String[] args) throws Exception{
      SAVE_HOOK=new FinalizeEscapseGC();
    
      // 对象第一次拯救自己
      SAVE_HOOK=null;
      System.gc();
      //finalize 优先级极地,等待0.5秒
      Thread.sleep(500);
    
      if (SAVE_HOOK != null) {
          SAVE_HOOK.isAlive();
      } else {
          System.out.println("no ,FinalizeEscapseGC target am dead");
      }
    
      // 下面这段代码跟上面一样,但是这次自救却失败了
      SAVE_HOOK=null;
      System.gc();
      //finalize 优先级极地,等待0.5秒
      Thread.sleep(500);
    
      if (SAVE_HOOK != null) {
          SAVE_HOOK.isAlive();
      } else {
          System.out.println("no ,FinalizeEscapseGC target am dead");
      }
    
      }
    
      }
      /*  运行结果:
      finalize method executed!
       yes, i am still alive
       no ,FinalizeEscapseGC target am dead
    
      从代码清单的运行结果可以看出, SAVE HOOK对象的 finalize方法确实被GC收集器触发过,并且在被收集前成功逃脱了
      另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对
      象的 finalize0方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize0方法不会被再次执行,因此第二
      段代码的自救行动失败.
      finalize运行的代价昂贵,不确定性大,无法保证各个对象的调用顺序,,它能做的所有工作,使用try-finally或者其它方式都可以做的更好
    
      */

3.2 垃圾收集算法

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

  • 复制算法
      为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
    技术分享图片

      现在商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。虚拟机HotSpot默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当 Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保( Handle Promotion)

  • 标记整理算法
      复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另外一种标记-整理算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

    技术分享图片

  • 分代收集算法
      这种算法并没有什么新的思想只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中每次垃圾收集时都发现有大批对象死去只有少量存活那就选用复制算法只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保就必须使用“标记一清理”或者“标记一整理”算法来进行回收。

3.3 内存分配与回收策略

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

  MinorGC:清理新生代
  MajorGC:清理老年代
  FullGC :清理整个堆空间

  • 对象优先在Eden分配
      大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。另:-XX:+PrintGCDetails收集器日志参数,告诉虚拟机发生垃圾回收时,打印日志

  • 大对象直接进入老年代
      大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。虚拟机提供-XX:PretenureSizeThreshold,令大于这个设置值的对象直接在老年代分配这样做的目的是避免Eden区及两个Servivor之间发生大量的内存复制

  • 长期存活的对象将进入老年代
      虚拟机给每个对象定义了一个年龄计数器,如果对象在Eden区出生并且经历过一次Minor GC后仍然存活,并且能够被Servivor容纳,将被移动到Servivor空间中,并且把对象年龄设置成为1.对象在Servivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度,就将会被晋级到老年代中。对于晋升到老年代的年龄阈值可以通过-XX:MaxTenuringThreshold设置

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

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

  • Minor GC和Full GC有什么不一样吗?
      新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
      老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行 Full GC的策略选择过程)。Full GC的速度一般会比Minor GC慢10倍以上。

第四章 虚拟机性能监控和故障工具(待服务器恢复在研究)

4.1 jdk命令行工具

4.2 jdk可视化工具

4.3 调优案例分析与实战

第三部分

第五章 虚拟机类加载机制

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

  在Java语言里面,类型的加载。连接和初始化过程都是在程序运行期间完成的,这种策略java提供了高度灵活性,java天生可以动态扩展的语言特性就是依赖运行期动态加载和连接这个特点实现的。

5.1 类加载的时机

  技术分享图片

  类被加载到虚拟机内存中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段
  加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以再初始化阶段之后再开始,这个是为了支持Java语言运行时绑定(也成为动态绑定或晚期绑定)

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

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

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

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

  • 当虚拟机启动时候,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类

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

  被动引用:

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

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

    例:DemoClass[] arr=new DemoClss[10];
    但是会初始化Lorg.fenixsoft.classloading.SuperClass
    该类由虚拟机自动生成,继承自Object。

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

5.2 类的加载过程

5.2.1 加载

加载阶段虚拟机完成的3件事:
  1)通过一个类的全限定名类获取定义此类的二进制字节流

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

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

怎么获取二进制字节流?

  1)从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础

  2)从网络中获取,这种场景最典型的应用就是Applet

  3)运行时计算生成,这种常见使用得最多的就是动态代理技术

  4)由其他文件生成,典型场景就是JSP应用

  5)从数据库中读取,这种场景相对少一些(中间件服务器)
数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的

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

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

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

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

虽然加载尚未完成,连接阶段可能也肯能开始了,但是加载与连接阶段依然存在固定的执行顺序

5.2.1 验证

  验证是连接阶段的第一步,这一阶段的目的是确保claa字节流中的信息符合虚拟机规范,并且不会危害虚拟机自身的安全。

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

  • 文件格式验证
      第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这个阶段的验证时基于二进制字节流进行的,只有通过类这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

这一阶段可能包括:

  1).是否以魔数oxCAFEBABE开头

  2).主、次版本号是否在当前虚拟机处理范围之内

  3.)常量池的常量中是否有不被支持的常量类型(检查常量tag标志)

  4.)指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量

  5.)CONSTANT_Itf8_info 型的常量中是否有不符合UTF8编码的数据

  6.)Class文件中各个部分及文件本身是否有被删除的或附加的其他信息等等

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

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

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

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

  4.类中的字段、方法是否与父类产生矛盾(列如覆盖类父类的final字段,或者出现不符合规则的方法重载,列如方法参数都一致,但返回值类型却不同等)等等

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

  1.保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,列如,列如在操作数栈放置类一个int类型的数据,使用时却按long类型来加载入本地变量表中

  2.保证跳转指令不会跳转到方法体以外的字节码指令上

  3.保证方法体中的类型转换时有效的,列如可以把一个子类对象赋值给父类数据类型,这个是安全的,但是吧父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的等等

  • 符号引用验证
      发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

  1.符号引用中通过字符串描述的全限定名是否能找到相对应的类

  2.在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段

  3.符号引用中的类、字段、方法的访问性是否可被当前类访问

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

5.2.2 准备

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

  假设public static int value = 123;那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器

5.2.3 解析

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

5.2.4 初始化

  类的初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才正真开始执行类中定义的Java程序代码(或者说是字节码)

5.3 类加载器

  • 类与类加载器
      对于任意一个类,都需要有它的类加载器和类本身来确定唯一性,换句话说,比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。

  • 双亲委派模型

    技术分享图片

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

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

    • 从java程序的角度,一般会提供3中类加载器:
        1. 启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA+HOME>lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是JVM识别的(仅按照文件名识别,如rt.jar,如果名字不符合,即使放在lib目录中也不会被加载),加载到虚拟机内存中,启动类加载器无法被JAVA程序直接引用。
        2. 扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载

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

    • 这样做的好处就是
        Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱
        就是保证某个范围的类一定是被某个类加载器所加载的,这就保证在程序中同 一个类不会被不同的类加载器加载。这样做的一个主要的考量,就是从安全层 面上,杜绝通过使用和JRE相同的类名冒充现有JRE的类达到替换的攻击方式

第六章

6.1 java内存模型与线程

  Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

  Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(类似于操作系统中处理器与主内存之间的高速缓存),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示:

技术分享图片

  关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成

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

  unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

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

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

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

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

  store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

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

  如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存 模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是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操作,变量才会被解锁。lock和unlock必须成对出现。

  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。

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

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

6.2 重排序

  在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:

  1.编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。

  2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3.内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

  从Java源代码到最终实际执行的指令序列,会经过下面三种重排序:

  为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。Java内存模型把内存屏障分为LoadLoad、LoadStore、StoreLoad和StoreStore四种

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

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

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

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

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

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

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

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

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

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

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

  Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”( Within- Thread As- f-Serial Semantics),后半句是指“指令重排序”现象和“工作
内存与主内存同步延迟”现象。
  Java语言提供了 volatile和 synchronized两个关键字来保证线程之间操作的有序性,
volatile关键字本身就包含了禁止指令重排序的语义,而 synchronized则是由“一个变量在同
一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个
锁的两个同步块只能串行地进入

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 线程

1. Java线程调度

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

  抢占式调度:线程的执行时间由系统来分配,线程的切换不有线程本身决定

2. 状态转换

  • 1.新建

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

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

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

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

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

3. 线程实现中
  相比于Synchronized,ReentrantLock增加了一些高级功能

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

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

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

七、逃逸分析

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

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

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

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

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

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

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















































































以上是关于深入java虚拟机-jvm高级特性和实战的主要内容,如果未能解决你的问题,请参考以下文章

《深入理解Java虚拟机:JVM高级特性与最佳实践》PDF下载

《深入理解Java虚拟机 - Jvm高级特性与最佳实践(第三版)》阅读笔记

《深入理解Java虚拟机 - Jvm高级特性与最佳实践(第三版)》阅读笔记

每周一书《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》分享!

每日一书:深入理解Java虚拟机:JVM高级特性与最佳实践

深入理解Java虚拟机(JVM高级特性与最佳实践)读后感