深入理解Java虚拟机(Java高阶读书笔记)

21 篇文章 1 订阅
11 篇文章 1 订阅

深入理解Java虚拟机 - JVM高级特性与最佳实践(周志明)第2版

只要看第2章、第3章、第4章、第5章简单看一看、第六章看6.1和6.2、第7章以及第12和13章。12和13属于并发里面的补充。上面这些都是重点,面试的典型问题,包括之前讲过的GC,内存模型、调优、常用命令、类加载、OOM和stackOverflow等。还有就是对象的生命周期一些,这本书大多是记忆类的,大家多多总结,多翻几遍~

文章目录

第2章 Java内存区域与内存溢出异常

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

2.1 概述

  • 对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力 的“皇帝”又是从事最基础工作的“劳动人民”——既拥有每一个对象的“所有权”,又担负着每 一个对象生命开始到终结的维护责任。
  • 对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操 作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题
  • 不过,也正是因为Java程序员把内存控制的权力交给了Java虚拟机, 一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误 将会成为一项异常艰难的工作

2.2 运行时数据区域

  • Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁
    在这里插入图片描述
Java内存区域图
2.2.1 程序计数器
  • 线程私有区域
  • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选 取下一条需要执行的字节码指令
  • 由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指 令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域 是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
2.2.2 Java虚拟机栈
  • 线程私有区域
  • 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的 生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型
  • 一个线程对应一个虚拟机栈,一个方法对应一个栈帧
  • 栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口
    等信息
  • 在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部 分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如 果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常
2.2.3 本地方法栈
  • 线程私有区域
  • 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间 的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚 拟机使用到的Native方法服务
  • 与虚拟机栈一样,本地方法 栈区域也会抛出StackOverflowErrorOutOfMemoryError异常
2.2.4 Java堆
  • 线程公有区域
  • 对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就 是存放对象实例,所有的对象实例以及数组都要在堆上分配(随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了)
  • Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)
  • 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
2.2.5 方法区
  • 线程公有区域
  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
  • 原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代 来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代 有-XX:MaxPermSize的上限
  • Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以 选择固定大小或者可扩展外,还可以选择不实现垃圾收集
  • 根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError异常
2.2.6 运行时常亮池
  • 运行时常量池是方法区的一部分
  • Class文件中除了有类的版 本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于 存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放
  • 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,并非预置入Class文件中常量池的内容才能进入方 法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较 多的便是String类的intern()方法
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申 请到内存时会抛出OutOfMemoryError异常
2.2.7 直接内存
  • 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规 范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现
  • NIO(New Input/Output)类(JDK 1.4后),引入了一种基于通道(Channel)与缓 冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存
  • 服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略 直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制), 从而导致动态扩展时出现OutOfMemoryError异常

2.3 HotSpot 虚拟机对象探秘

2.3.1 对象的创建

对象的创建我总结下来分为5步:

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

  2. 接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类 加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来
    ***一个内存分配的小知识:(内存如何分配、组织)

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

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

      选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

    ***一个内存分配的小知识:(内存分配的并发同步问题)
    在并发情况下也并不是线程安全的, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况:

    1. 一种是对分配内存空间的动作进行同步处理 ——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
    2. 把内存分 配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内 存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内 存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。 虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
  3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行

  4. 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找 到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息

  5. 一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来

2.3.2 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

  • 对象头
    • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
    • 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指 针来确定这个对象是哪个类的实例,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据
  • 实例数据
    实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类 型的字段内容
  • 对齐填充
    由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说, 就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍), 因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.3.3 对象的访问定位

我们的Java程序需要通过栈上的reference数据来操作堆上的 具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄直接指针两种。

  • 使用句柄
    如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中 存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如图所示
    在这里插入图片描述
  • 直接指针
    使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的 相关信息,而reference中存储的直接就是对象地址
    在这里插入图片描述
    这两种对象访问方式各有优势:
    • 使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
    • 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销, 由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本

2.4 实战:OutOfMemoryError异常

  • 除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError异常的可能
  • Java堆内存的OOM异常是实际应用中常见的内存溢出异常情况。当出现Java堆内存溢出 时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heap space”
  • 虚拟机栈和本地方法栈溢出(难点)
    • 由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽 然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数(设置虚拟机栈大小)设定。 关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
      • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
      • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常
    • 实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常
    • 如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常
      • 原因:操作系统分配给每个进程的内存是有限制的,譬如32位的Windows 限制为2GB。虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在 内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
    • 如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情 况下)达到1000~2000完全没有问题,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程如果没有这方面的 处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到

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

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想 进去,墙里面的人却想出来

3.1 概述

  • 1960年诞生于MIT的Lisp是第一门真正使用内存动态分 配和垃圾收集技术的语言
  • 当Lisp还在胚胎时期时,人们就在思考GC需要完成的3件事情:
    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?
  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节

3.2 对象已死吗

3.2.1 引用计数算法
  • 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的
  • 引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但是,至少主流 的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题
    /** 
    *testGC()方法执行后,objA和objB会不会被GC呢? 
    *@author zzm 
    */ 
    public class ReferenceCountingGC{ 
    	public Object instance=null; 
    	private static final int_1MB=1024*1024/** 
    	 *这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
    	 */ 
    	private byte[]bigSize=new byte[2*_1MB]public static void testGC(){ 
    		ReferenceCountingGC objA=new ReferenceCountingGC(); 
    		ReferenceCountingGC objB=new ReferenceCountingGC(); 
    		objA.instance=objB; 
    		objB.instance=objA; 
    		objA=null; 
    		objB=null; 
    		//假设在这行发生GC,objA和objB是否能被回收? 
    		System.gc(); 
    	} 
    } 
    
3.2.2 可达性分析算法

在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中, 都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的在这里插入图片描述

  • 在Java语言中,可作为GC Roots的对象包括下面几种:
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性引用的对象。
    • 方法区中常量引用的对象。
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
3.2.3 再谈引用

Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱

  • 强引用。强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引 用。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2之后,提供了PhantomReference类来实现虚引用。
3.2.4 生存还是死亡
  1. 一个对象在确定回收之前要经过2次标记,在第一次标记为回收对象后,对象仍然有机会拯救自己:

    • 第一次标记:如果对象在进行可达 性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法(如果此对象之前执行过finalize方法,则不需要进行第二次标记,直接可以垃圾回收
    • 第二次标记:如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做 F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合
  2. 任何一个对象的finalize()方法都只会被系统自动调用一次, 如果对象面临下一次回收,它的finalize()方法不会被再次执行

  3. 并不鼓励大家使用这种finalize方法来拯救对象,相反,建议大家尽量避免使用它,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉Java语言中有这个方法的存在

3.2.5 回收方法区
  • Java虚拟机规范中说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集 的“性价比”一般比较低
  • 永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类
    • 回收废弃常量与回收 Java堆中的对象非常类似,没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池
    • 判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则 相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”
      • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
      • 加载该类的ClassLoader已经被回收。
      • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该 类的方法。

3.3 垃圾收集算法

3.3.1 标记-清除算法
  • 步骤:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
  • 它的主要不足有两个:
    • 一个是效率问题,标记和清除两个过程的效率都不高
    • 另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
      在这里插入图片描述
3.3.2 复制算法
  • 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
  • 实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点
    在这里插入图片描述
  • 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)
  • 内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时, 这些对象将直接通过分配担保机制进入老年代
3.3.3 标记-整理算法
  • 让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
    在这里插入图片描述
3.3.4 分代收集算法
  • 当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法, 这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块
  • 一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
    • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
    • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收

3.4 HotSpot的算法实现

3.4.1 枚举根节点
  • 从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间
  • Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的
  • 在 HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的 时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了
  • 记录一段代码中存对象引用的位置,这样虚拟机就不用全部看上下文了,直接看safe point就可以了
3.4.2 安全点
  • HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint)
  • 安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行 的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行
  • 另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行 JNI调用的线程)都“跑”到最近的安全点上再停顿下来,这里有两种方案可供选择:抢先式 中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)
    • 抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件
    • 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。 轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方
3.4.3 安全区域
  • Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint
  • 安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint
  • 在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离 开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完 成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止

3.5 垃圾收集器

在这里插入图片描述

  • 如果两个收集器之间存在连线,就说明它们 可以搭配使用
3.5.1 Serial收集器
  • Serial收集器是最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机 新生代收集的唯一选择, 这个收集器是一个单线程的收集器, 它 的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作, 更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束
    在这里插入图片描述
  • 从Serial收集器到Parallel收集器,再到 Concurrent Mark Sweep(CMS)乃至GC收集器的最前沿成果Garbage First(G1)收集器,我 们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断 缩短,但是仍然没有办法完全消除(这里暂不包括RTSJ中的收集器)
  • 它依然是虚拟机运行在Client模式下的默认新生代收集器。 它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比)
  • Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择
3.5.2 ParNew收集器
  • ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对 象分配规则、回收策略等都与Serial收集器完全一样
    在这里插入图片描述
  • ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作
  • CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器 Parallel Scavenge配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选
    择ParNew或者Serial收集器中的一个
  • ParNew收集器也是使用-XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。
  • ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在 线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源 的有效利用还是很有好处的
3.5.3 Parallel Scavenge收集器
  • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)
  • 所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%
  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务
  • Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集 停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参 数
  • GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每 次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了
  • 由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器
  • Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、 Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX: PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信 息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)
  • 自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别
3.5.4 Serial Old收集器
  • Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法 。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式 下,那么它主要还有两大用途:
    • 一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge 收集器搭配使用
    • 另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent
      Mode Failure
      时使用
      在这里插入图片描述
3.5.5 Parallel Old收集器
  • Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
  • 由于老年代Serial Old收集器在服务端(server模式下)应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合(Parallel Scavenge + Serial)的吞吐量甚至还不一定有ParNew加CMS的组合“给力”
  • 在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old 收集器
    在这里插入图片描述
3.5.6 CMS收集器
  • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
  • 从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现 的
  • 它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:
    • 初始标记(CMS initial mark) (stop the world)
    • 并发标记(CMS concurrent mark) (这一阶段用户线程可以工作)
    • 重新标记(CMS remark) (stop the world)
    • 并发清除(CMS concurrent sweep)(并发)
  • 初始标记仅仅只是 标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC RootsTracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变 动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远 比并发标记的时间短
  • 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
    在这里插入图片描述
  • CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿
  • 但是CMS还远达不到完美的程度,它有以下3个明显的缺点:
    • CMS收集器对CPU资源非常敏感。当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程。为了应付这种情况, 虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的 CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让GC线程、用户线程交替运行,尽量减少GC线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些, 也就是速度下降没有那么明显。实践证明,增量时的CMS收集器效果很一般,在目前版本中,i-CMS已经被声明为“deprecated”,即不再提倡用户使用
    • CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。(如果出现Concurrent Mode Failure,使用SerialOld备用方案)。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。如果在 应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来 提高触发百分比,以便降低内存回收次数从而获取更好的性能。但是参数-XX:CM SInitiatingOccupancyFraction设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低
    • CMS是一款基于“标记—清除”算法实现的收集器,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于 设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)
  • Full GC/MajorGC:老年代GC;MinorGC:新生代GC
3.5.7 G1收集器
  • G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,早在JDK 1.7刚刚确立项目目标,Sun公司给出的JDK 1.7 RoadMap里面,它就被视为JDK 1.7中HotSpot虚拟机 的一个重要进化特征
  • G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是(在比较长 期的)未来可以替换掉JDK 1.5中发布的CMS收集器
  • 与其他GC收集器相比,G1具备如下特 点
    • 并行与并发。G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者 CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的 GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行
    • 分代收集。虽然G1可以不需要其 他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已 经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果
    • 空间整合。G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的
    • 可预测的停顿。这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一 个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实 时Java(RTSJ)的垃圾收集器的特征了
  • 在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这 样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分 为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和 老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合
  • G1跟踪各个Region里面的垃圾堆积的价值大小回收所获得的 空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)
  • 在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对 应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个 Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代 的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
  • 如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
    • 初始标记(Initial Marking)
    • 并发标记(Concurrent Marking)
    • 最终标记(Final Marking)
    • 筛选回收(Live Data Counting and Evacuation)
  • 对CMS收集器运作过程熟悉的读者,一定已经发现G1的前几个步骤的运作过程和CMS 有很多相似之处。初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值。并发标记阶段是从GC Root开始 对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序, 根据用户所期望的GC停顿时间来制定回收计划
    在这里插入图片描述
3.5.8 理解GC日志

自己看书,只有2页

3.5.9 垃圾收集器参数总结

内存管理和垃圾收集中的参数总结:

参数描述
-XmxJvm最大内存分配池(最大堆)
-Xms初始内存非配池
-Xmn新生代大小
-Xss每个线程栈的大小(虚拟机栈)
-Xoss每个线程栈的大小(本地方法栈)
-XX:MaxDirectMemorySize本机直接内存的大小
-XX:MaxPermSize方法区最大分配
-XX:PermSize初始方法区大小
-XX:noclassgc是否对方法区中的类进行回收
-XX:CMSInitiatingOccupancyFraction设置内存使用比例,CMS收集中,老生代使用高于这个值,就回会触发CMS
-XX:UseCMSCompactAtFullCollection不得不进行FullGC时,进行一次内存碎片的合并整理
-XX:CMSFullGCsBeforeCompaction表示执行多少次不带压缩的FullGC时,进行一次带压缩的FullGC
-XX:PretenureSizeThreshold设置大于这个值大小的对象直接在老年代分配
-XX: MaxTenuringThreshold对象晋升老年代的年龄阈值
HandlePromotionFailure是否允许担保失败

在这里插入图片描述

3.6 内存分配与回收策略

  • Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存
  • 对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标 量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配
3.6.1 对象优先在Eden分配
  • 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代GC)
  • 注意,作者多次提到的Minor GC和Full GC有什么不一样吗?
    • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
    • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴 随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行 Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
3.6.2 大对象直接进入老年代
  • 大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组
  • 虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习 一下:新生代采用复制算法收集内存)
  • PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不 认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场 合,可以考虑ParNew加CMS的收集器组合
3.6.3 长期存活的对象将进入老年代
  • 虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被 Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中 每“熬过”一次Minor GC,年龄就增加1岁
  • 对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置
3.6.4 动态对象年龄判定
  • 虚拟机并不是永远地要求对象的年龄必须达到 了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
3.6.5 空间分配担保
  • 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机 会查看HandlePromotionFailure设置值是否允许担保失败如果允许,那么会继续检查老年代 最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行 一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC
  • 老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多 少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋 升到老年代对象容量的平均大小值作为经验值
  • 如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避 免Full GC过于频繁
  • JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就 会进行Minor GC,否则将进行Full GC

3.7 本章小结

  • 本章介绍了垃圾收集的算法、几款JDK 1.7中提供的垃圾收集器特点以及运作原理。通过代码实例验证了Java虚拟机中自动内存分配及回收的主要规则
  • 内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、 实现方式选择最优的收集方式才能获取最高的性能。没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。因此,学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数

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

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

4.1 概述
  • 给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC日志、线程快照
    (threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等
4.2 JDK的命令行工具

在这里插入图片描述
在这里插入图片描述

4.2.1 jps:虚拟机进程状况工具
  • 可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称
    以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)
4.2.2 jstat:虚拟机统计信息监视工具
  • jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具
  • jstat命令格式为

    jstat[option vmid[interval[s|ms][count]]]

    • 对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与
      LVMID是一致的
    • 参数interval和count代表查询间隔和次数,如果省略这两个参数,说明只查询一次。假设
      需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:jstat-gc 2764 250 20
    • 选项option代表着用户希望查询的虚拟机信息,主要分为3类:j类装载、垃圾收集、运行
      期编译状况
      ,具体选项及作用请参考表4-3中的描述:
      在这里插入图片描述
4.2.3 jinfo:Java配置信息工具
  • jinfo(Configuration Info for Java)的作用是实时地查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了
  • jinfo在Windows和Linux平台都有提供,并且加入了运行期修改参数的能力,可以使用-flag[+|-]name或者-flag name=value修改一部分运行期可写的虚拟机参数值。JDK 1.6中,jinfo对于Windows平台功能仍然有较大限制,只提供了最基本的-flag选项。
执行样例:
查询CMSInitiatingOccupancyFraction参数值(设置CMS收集时,老年代比例高于这个值就会开始一次FullGC)。
C:\>jinfo-flag CMSInitiatingOccupancyFraction 1444
-XX:CMSInitiatingOccupancyFraction=85
4.2.4 jmap:Java内存映像工具
  • jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdumpdump文件)
  • 其他方式:
    • -XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在OOM异常出现之后自动生成dump文件
    • 通过-XX:+HeapDumpOnCtrlBreak参数则可以使用[Ctrl]+[Break]键让虚拟机生成dump文件
    • 又或者在Linux系统下通过Kill-3命令发送进程退出信号“吓唬”一下虚拟机,也能拿到dump文件
  • jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永
    久代的详细信息,如空间使用率、当前用的是哪种收集器
    等。
    在这里插入图片描述
4.2.5 jhat:虚拟机堆转储快照分析工具
  • Sun JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,可以在浏览器中查看。
4.2.6 jstack:Java堆栈跟踪工具
  • jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。
  • 线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因
  • 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
jstack命令格式: 	
jstack[option]vmid
  • option选项的合法值与具体含义见表4-5。
    在这里插入图片描述
  • 在JDK 1.5中,java.lang.Thread类新增了一个 getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。使用这个方法可以通过简单的几行代码就完成jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈
4.2.7 HSDIS:JIT生成代码反汇编

JIT(just-in-time compilation) 动态运行时编译/即时编译
反汇编与反编译不同!!!

  • 我们分析程序的执行语义问题(虚拟机做了什么)时,在字节码层面上分析完全可行,但分析程序的执行行为问题(虚拟机是怎样做的、性能如何)时,在字节码层面上分析就没有什么意义了,需要通过其他方式解决
4.3 JDK的可视化工具

JDK中除了提供大量的命令行工具外,还有两个功能强大的可视化工具: JConsole和VisualVM,这两个工具是JDK的正式成员,没有被贴上“unsupported and experimental”的标签。

4.3.1 JConsole:Java监视与管理控制台

在这里插入图片描述

4.3.2 VisualVM:多合一故障处理工具

第6章 类文件结构

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

6.1 概述

10多年时间过去了,今天的计算机仍然只能识别0和1,但由于最近10年内虚拟机以及大量建立在虚拟机之上的程序语言如雨后春笋般出现并蓬勃发展,将我们编写的程序编译成二进制本地机器码(NativeCode)已不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式

6.2 无关性的基石
  • Java在刚刚诞生之时曾经提出过一个非常著名的宣传口号:“一次编写,到处运行(Write Once,Run Anywhere)”,这句话充分表达了软件开发人员对冲破平台界限的渴求。
  • Sun公司以及其他虚拟机提供商发布了许多可以运行在各种不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”
  • 各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,但本节标题中刻意省略了“平台”二字,那是因为笔者注意到虚拟机的另外一种中立特性——语言无关性正越来越被开发者所重视
  • 时至今日,商业机构和开源机构已经在Java语言之外发展出一大批在Java虚拟机之上运行的语言,如Clojure、Groovy、JRuby、Jython、Scala等。
  • 实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。基于安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。
  • 使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以把程序代码编译成Class文件,虚拟机并不关心Class的来源是何种语言
    在这里插入图片描述
  • Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别于Java的语言特性提供了基础

第7章 虚拟机类加载机制

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

7.1 概述
  • Java在程序运行期间进行类加载
  • 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
  • 与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的
7.2 类加载的时机
  • 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)
    在这里插入图片描述

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

  • 什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

    • 1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    • 2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    • 3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    • 4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
    • 5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  • 对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

  • 接口与类真正有所区别的是前面讲述的5种“有且仅有”需要开始初始化场景中的第3种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化

7.3 类加载的过程
7.3.1 加载
  • “加载”是“类加载”(Class Loading)过程的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,虚拟机需要完成以下3件事情:
    • 1)通过一个类的全限定名来获取定义此类的二进制字节流。
    • 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    • 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
  • 加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
7.3.2 验证
  • 验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
  • Java语言本身是相对安全的语言(依然是相对于C/C++来说),使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。
  • 验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分
  • 验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证
    • 1.文件格式验证
      第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

      • 是否以魔数0xCAFEBABE开头。
      • 主、次版本号是否在当前虚拟机处理范围之内。
      • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
      • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
      • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
      • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

      该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区, 之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

      • 2.元数据验证
        第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:

        • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
        • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
        • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。

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

      • 3.字节码验证

        • 第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

          • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
          • 保证跳转指令不会跳转到方法体以外的字节码指令上。
          • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
        • 如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检查,也不能保证这一点。

    • 4.符号引用验证

      • 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:
        • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
        • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
        • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。
      • 符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如
        java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
      • 对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
7.3.3 准备

为静态字段分配空间和零值

  • 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量在这里插入图片描述
7.3.4 解析

符号引用 -----> 直接引用

  • 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
    • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
    • 直接引用(Direct References).:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
  • 虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、 checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、 invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic这16个用于操作符号引用的字节码指令之前
  • 对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行
  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型
    • 1.类或接口的解析
      递归的进行解析,直到返回直接引用
      假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:

      • 1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
      • 2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类
        似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
      • 3)如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
    • 2.字段解析
      字段例如public static int A = 1;。调用步骤:解析类—>自身—>父接口—>父类—>异常
      要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index[2]项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。

      • 1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
      • 2)否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
      • 3)否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
      • 4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。

      在实际应用中,虚拟机的编译器实现可能会比上述规范要求得更加严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译

    • 3.类方法解析
      特指static方法。调用步骤:解析类—>自身—>父类—>父接口(如果父接口中找到,会返回异常,因为着代表是接口方法解析)—>异常
      类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index[3]项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

      • 1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现
        class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
      • 2)如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      • 3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      • 4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常。
      • 5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

      最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。

    • 4.接口方法解析
      特指接口中的static方法。调用步骤:解析类—>自身—>父接口—>异常
      接口方法也需要先解析出接口方法表的class_index[4]项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

      • 1)与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
      • 2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      • 3)否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      • 4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

      由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

7.3.5 初始化

只在使用时,才会进行这个阶段

  • 类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。
  • 从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程
    • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
    • <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。父类< clinit >在子类之前执行
    • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
    • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
    • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化
    • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕
7.4 类加载器

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

7.4.1 类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

7.4.2 双亲委派模型
  • 从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现[1],是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
  • 从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器
    • 启动类加载器(Bootstrap ClassLoader):前面已经介绍过,这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中
    /**
    Returns the class loader for the class.Some implementations may use null to represent the bootstrap class loader.This method will return null in such
    implementations if this class was loaded by the bootstrap class loader.
    */
    public ClassLoader getClassLoader(){
    	ClassLoader cl=getClassLoader0();
    	if(cl==null)
    		return null;
    	SecurityManager sm=System.getSecurityManager();
    	if(sm!=null){
    		ClassLoader ccl=ClassLoader.getCallerClassLoader();
    		if(ccl!=null&&ccl!=cl&&!cl.isAncestor(ccl)){
    			sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
    		}
    	}
    	return cl;
    }
    
    • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器
    • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher $App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
      在这里插入图片描述
  • 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载
  • 使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱
  • 双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中
protected synchronized Class<?>loadClass(String name,boolean resolve)throws ClassNotFoundException{
	//首先,检查请求的类是否已经被加载过了
	Class c=findLoadedClass(name);
	if(c==null){
		try{
			if(parent!=null){
				c=parent.loadClass(name,false);
			} else {
				c=findBootstrapClassOrNull(name);
			}
		} catch(ClassNotFoundException e){
			//如果父类加载器抛出ClassNotFoundException
			//说明父类加载器无法完成加载请求
		}
		if(c==null){
			//在父类加载器无法加载的时候
			//再调用本身的findClass方法来进行类加载
			c=findClass(name);
		}
	}
	if(resolve){
		resolveClass(c);
	}
	return c;
}
7.4.3 破坏双亲委派模型
  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2发布之前。由于双亲委派模型在JDK 1.2之后才被引入,而类加载器和抽象类java.lang.ClassLoader则在JDK 1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2之后的java.lang.ClassLoader添加了一个新的protected方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。JDK 1.2之后已不提倡用户再去覆盖loadClass()方法,而应当把自己的类加载逻辑写到findClass()方法中,
  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换(HotSwap)、模块热部署(Hot Deployment)等,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用停机也不用重启。对于个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者具有很大的吸引力。在Java程序员中基本有一个共识:OSGi中对类加载器的使用是很值得学习的,弄懂了OSGi的实现,就可以算是掌握了类加载器的精髓。
  • 2
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java虚拟机(JVM)是Java程序的运行环境,它负责将Java字节码转换为可执行的机器码并执行。深入理解Java虚拟机涉及了解JVM的工作原理、内存管理、垃圾回收、类加载机制等方面的知识。 首先,JVM的工作原理是通过解释器或即时编译器将Java字节码转换为机器码并执行。解释器逐条执行字节码指令,而即时编译器将字节码转换为本地机器码,以提高程序的执行效率。 其次,内存管理是JVM的重要任务之一。JVM将内存分为不同的区域,包括堆、栈、方法区等。堆用于存储对象实例,栈用于存储局部变量和方法调用信息,方法区用于存储类的信息。JVM通过垃圾回收机制自动回收不再使用的对象,释放内存空间。 此外,类加载机制也是深入理解JVM的关键内容之一。类加载是将类的字节码加载到内存中,并进行验证、准备、解析等操作。类加载器负责查找并加载类的字节码,而类加载器之间存在着父子关系,形成了类加载器层次结构。 还有其他一些与性能优化、调优相关的内容,如即时编译器的优化技术、垃圾回收算法的选择等,也是深入理解Java虚拟机的重要方面。 总的来说,深入理解Java虚拟机需要对JVM的工作原理、内存管理、垃圾回收、类加载机制以及性能优化等方面有较深入的了解。掌握这些知识可以帮助开发人员编写出更高效、稳定的Java程序。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值