jvm学习笔记

基于栈和基于寄存器的指令集架构

Jvm前端编译器架构=都是= 基于栈 的指令集架构,与之对应的还有 基于寄存器 的指令集架构。### 基于栈的指令集架构

* 跨平台性好、指令集小、指令多、性能相较于寄存器更差
* 例:

file

基于寄存器的指令集架构

* 直接使用cpu的指令集,故执行幸能更好,但是移植性较差

Hotspot/JRocket/J9

JRocket:号称最快的虚拟机,专注于服务端,牺牲程序启动速度,因此其内部不包含解析器的实现,全部代码都即时编译器编译后执行

J9:运行IBM自己的软件时速度较快,J9最厉害的地方是它高度模块化,不但可以部署在桌面或服务器上,还可以部署到嵌入式环境中,例如CLDC级别的环境;这些环境用的是同一个J9核心VM,搭配上适用于具体环境的GC和JIT编译器。

hotspot:运用最广泛的虚拟机

类加载子系统

graph LR
加载 ---> 验证
subgraph 链接
验证 ---> 准备 ---> 解析
end
解析 ---> 初始化

类的加载过程

  1. 加载
    加载二进制流并产生对应的Class对象。
  2. 链接
    2.1 验证

    • 确保class文件的字节流满足当前虚拟机的要求,保证被加载类的正确性
    • 主要包含四种验证:文件格式验证(例如是否以魔数开头),元数据验证,字节码验证,符号引用验证
      2.2 准备
    • 为类变量分配内存并且设置该类变量(static修饰的变量)的默认初始值,即零值。如 int i = 3,则在此阶段将i赋值为0。
    • 注:这里不包含被final修饰的类变量,它在编译的时候就已经赋零值了,在准备阶段会显示初始化,即 i 赋值为3。
    • 准备阶段不会为实例变量初始化,类变量会分配到方法区中,而示例变量则会随着对象一起分配到java堆中。
      2.3 解析
    • 将常量池内的符号引用转换为直接引用的过程。
  3. 初始化
    • 初始化阶段就是执行类构造器方法()的过程。
    • 该方法不需要定义,由javac编译器自动生成(如果没有类变量或静态代码块就不会生成该方法)。不同于类的构造器,即()
    • 构造器方法中的指令语句按照源文件出现的顺序执行。
    • 若该类有父类,则一定要保证父类的()已经执行完毕了
    • 虚拟机必须保证一个类的()方法在多线程下被同步加锁。

file

file

类加载器

file

  1. 虚拟机规范中规定类加载器只有两种,启动类加载器(bootstrap class loader)和用户自定义类加载器(凡是直接或间接继承自ClassLoader类的均为用户自定义类加载器)

  2. Bootstrap class loader使用的是c/c++编写,其他类加载器使用java编写。

  3. 启动类加载器只加载核心类库(JAVAHOME/jre/lib/rt.jar resources.jar sun.boot.class.path路径下的类),一般使用getclassloader()方法获取不到启动类加载器(返回null)
    file

  4. 出于安全考虑,bootstrap只加载包名为java、javax、sun等开头的类

  • 为什么要自定义类加载器?

    1. 隔离加载类,比如一些中间件中可能会用到与项目中具有相同包名和类名的类,一般这些中间件就会自己实现类加载器。
    2. 修改类的加载方式,如在需要的时候再加载
    3. 扩展加载源,例如可以从各种不同渠道,比如网络、数据库等方式加载所需的类。
    4. 防止源码泄露,如对字节码加密后使用自定义类加载器解密
  • 实现自定义类加载器
    file

  • 判读两个class对象是否为同一个类?

    1. 包名和类名一致
    2. classloader一致

注:JVM必须知道一个类是由启动类加载器加载的还是由用户自定类加载器加载的。如果是由用户自定义类加载器加载,则JVM #会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中#,当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。(关键字:动态链接)

双亲委派机制

file

例如,如果在项目中创建了一个 java.lang.String 类,当new String()时,返回的仍然是核心api中的String

  • 优势
    1. 避免类的重复加载
    2. 保护系统安全,防止核心api被篡改(即沙箱安全机制),举例如上图。

类的主动使用和被动使用

file

注:详见:10.类的生命周期,11.类加载器

运行时数据区

graph 
subgraph Runtime
程序计数器
虚拟机栈
本地方法栈
堆
元空间
end
  1. StackOverflowError vs OutOfMemoryError

    • Java虚拟机规范允许栈的大小是动态的或者固定不变的。
    • 如果是固定不变的,当线程请求分配栈的容量超过指定的最大容量,则会抛出 StackOverflowError 异常
    • 如果是动态扩展的,并且在尝试扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,则抛出 OutOfMemoryError 异常
  2. 设置栈的固定大小

    • -Xss
      file
  3. 栈帧的内部结构

    • 局部变量表(Local Variables)
    • 定义为一个数字数组【图1】,主要用于存储方法参数和定义在方法体内部的局部变量,这些数据类型包括各类的基本数据类型,对象引用(reference),以及returnAddress类型。
    • 局部变量表所需的容量大小是在编译器确定下来的,并保存在方法的Code属性的 maximum local variables 数据项中。方法运行期间不会改变其大小。
    • 线程安全
    • 一开始创建时候是一个定长的空数组,执行字节码指令时遇到局部变量才将其放入该表,并在表中存如该值(对象存引用)。其中表的Slot会重复利用
    • 基本类型放的是值,引用类型放的是索引。使用表的index获取
      file
  • 操作数栈(Operad Stack)
  • 动态链接(指向运行时常量池的方法引用)
  • 方法返回地址
  • 附加信息

【图1】
file

3.1. 关于Slot的理解

  • 局部变量表最基本的存储单元是Slot
  • 局部变量表中存放编译期可知的各种基本数据类型(8种)、引用数据类型(reference)、returnAddress类型的变量。
  • 在局部变量表中,32位以内的类型只占一个Slot(包括returnAddress类型),64位的(long/double)占两个Slot。
  • byte、short、char在存储前被转换为int,boolean也被转换为int,其中,0代表false,非0代表true。
  • this也是一个局部变量,保存在局部变量表index为0的Slot,静态方法没有this
  • 局部变量必须显式赋值才能使用

3.2. 操作数栈(jvm解释引擎是基于栈的执行引擎的原因)

  • 每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出的操作数栈(表达式栈)
  • 在方法执行过程中,根据字节码指令,往栈中写入或提取数据,主要用于保存计算过程中的中间结果。
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中
  • 栈的深度在编译期就确定了
    file

3.3. 栈顶缓存技术
基于栈的架构的虚拟机使用零地址指令更加紧凑,但完成一项操作需要更多的入栈和出栈指令,也就意味着更多次数的内存读写,Hotspot提出了,将栈顶元素全部缓存在物理cpu的寄存器中,以此降低对内存的读写次数,提升引擎的执行效率

  1. 动态链接

    • 每一个栈帧内部包含一个指向运行时常量池中该栈帧所属方法的引用地址
  2. 方法的调用
    JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
    5.1. 静态链接(对应早期绑定,绑定范围更大,包括属性方法等,链接只表示方法)
    当一个字节码文件被装载进jvm内部时,如果被调用的目标方法在编译时可知,且运行期间长期保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
    5.2. 动态链接(对应晚期绑定)
    如果被调用的方法在编译期间无法确定(多态),只能在程序运行期间将调用方法的符号引用转换为直接引用的过程。

  • java中任何一个普通的方法都具有虚函数的特征,相当于c++中的虚函数(c++中需要使用 关键字 virtual 显式定义)。如果不希望某个方法拥有虚函数的特征,可以使用final标记该方法
  • 非虚方法:如果该方法在编译期就确定了具体的调用版本,且运行时不可变。
    • 静态方法、私有方法、final方法、实例构造器、父类方法都是非需方法。
    • 其他方法均为虚方法。
    • 可以根据子类对象的多态性的使用前提判断:1. 类的继承关系 2. 方法是否可被重写
      javap -v可查看执行指令
      file

5.3. 动态类型语言vs静态类型语言
区别在于对类型的检查是在编译时期(静态)还是运行期(动态),通俗解释为,静态类型语言是判断变量自身类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有。(java原本署于静态类型语言,但在引入了 invokedynamic 指令之后(java8中的lambda表达式使用该指令)就具有了动态类型语言的特性,js为动态类型)

5.4. returnAddress
存储的该方法的pc寄存器的值(即下一条该执行指令的地址值)
无论方法通过哪种方式退出(正常执行完成、异常退出),在方法退出后都会返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令地址。而异常退出时,返回地址时通过异常表来确定的,栈帧中一般不保存这部分的信息。异常退出的时候不会给调用者返回任何返回值。

  • 异常处理表
    file

  • 方法中定义的局部变量一定是线程安全?
    不一定,如果是参数传递的或者将变量return出去了就不安全,将变量return出去之后可能引起多线程争抢该返回值。
    file

如果某一变量生命周期只在方法内有效则线程安全

  1. 本地方法栈
    本地方法主要用于:语言扩展、与操作系统交互
  • 当某个线程调用一个本地方法时,它就进入了一个全新且不受jvm限制的世界,它和jvn拥有同样的权限。
  • 并非所有jvm都支持本地方法
    • 栈、堆、方法区 关系
      file
  • 使用 -Xms(堆的起始内存大小) 和 -Xmx(最大内存,超过则OOM) 设置

    • -X 是jvm的运行参数
    • ms 为 memory start
    • 默认情况下,起始大小为电脑内存的 1/64,最大为 1/4
  • java8之后堆在逻辑上分为:新生代 + 老年代 + 元空间(逻辑署于,实际不属于)

  • 新生代和老年代占比使用 -XX:NewRatio=2 这样修改,表示新生代占1,老年代占2.

  • edge区和survivor区占比使用 -XX:SurvivorRatio=8

  • 使用 -Xmn 设置新生代最大内存,如果同时设置 NewRatio 则以 Xmn 为准

  • 对象分配的过程
    file

  • 对象分配的特殊情况

    • 新生代放不下新对象,则直接放到老年代。若老年代也放不下则执行full gc,若还放不下则OOM
    • 如果s0/s1区放不下时,直接将对象放到老年代,不执行gc(edge区放不下才会执行,并被动清除s区)
  • Minor GC、Major GC、Full GC

    • 针对HotSpot的实现,它里面的GC按照回收区域又分为 1. 部分收集(Partial GC) 2. 整堆收集(Full GC)
    • 部分收集:不是完全收集整个java堆。其又分为
      • 新生代收集(Minor GC / Young GC):只是新生代收集
      • 老年代收集(Major GC / Old GC):只是老年代收集(目前只有CMS GC会又单独收集老年代的行为,很多时候 问题中,会把Major GC和 Full GC搞混淆,需要具体分辨是老年代回收还是整堆回收)
      • 混合收集(Mixed GC):收集整个新生代及部分老年代(目前只有 G1 会这样做)
    • 整堆收集(Full GC):收集整个java堆和方法区
    • Major GC速度一般比MinorGC慢10倍以上,需重点优化。
  • 动态对象年龄判断
    如果Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。(因为每次两个S区复制来复制去,如果每次复制的对象都大于S区的一半,则可以预见下次GC的成果不大)

  • jdk7之后空间分配担保默认为true(发生minorGC之前会检查老年代可用空间是否大于年轻代所有对象之和,如果小于,则判断是否大于历次晋升对象平均大小,如果大于,则执行minorGC,但仍有风险)

  • 堆是分配对象的唯一选择吗?
    不是,如果经过逃逸分析(Escape analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配

  • 逃逸分析

    • 当一个对象在方法中被定义之后,若对象只在方法内部使用,则认为没有发生逃逸
    • 当一个对象在方法中被定义之后,若它被外部方法所引用,则认为发生了逃逸。例如作为调用参数传递到其他地方。
    • 如何快速判断是否发生了逃逸,就看new出的对象实体(区别实体和变量, String str (变量) = new String() (实体) )是否有可能在方法外被调用
    • -XX:+(-)DoEscapeAnalysis 开启(关闭)逃逸分析(需开启服务器模式 使用 -server 控制,hotspot中并没有使用该技术),jdk7之后默认开启
    • 代码优化
      • 同步消除:如果没有发生逃逸,可以考虑将对象锁清楚
      • 栈上分配:如果没有发生逃逸,可以考虑将对象new在栈上
      • 标量替换:有的对象可能不需要作为一个连续内存结构存储,那么对象的部分或全部可以不存储在堆,而是存储在栈。
        • 标量:无法再分解成更小的数据的数据,java中的原始数据就是标量。
        • 聚合量:还能再分解的数据叫聚合量,java中的对象就是聚合量。可以被分解成其他的聚合量和标量。
        • 比如一个对象中只包含两个int变量,则将其优化为该方法中的两个int类型的局部变量存储在本地变量表中。
        • 使用 –XX:EliminateAllocations 打开
  1. 方法区(元空间)
    • JDK7之前为永久代,之后修改为元空间(直接使用本地物理内存)。可以理解为永久代和元空间分别是方法区的两种实现。
    • java虚拟机规范申明其在逻辑上署于堆空间,但一般在具体实现上将其和堆分开,故其也成为非堆
  • 栈、堆、方法区的关系
graph
subgraph Java栈
subgraph 栈帧1
subgraph 本地变量表1
int1[int]
short1[short]
reference1[reference]
end
end

subgraph 栈帧2
subgraph 本地变量表2
int2[int]
short2[short]
reference2[reference]
end
end
end

subgraph Java堆
subgraph 对象实例1
classPoint1[到对象类型的指针]
instance1[对象实例数据]
end

subgraph 对象实例2
classPoint2[到对象类型的指针]
instance2[对象实例数据]
end
end

subgraph 方法区
classData[对象类型数据class]
end

reference1 ---> instance1
reference2 ---> instance2
classPoint1 & classPoint2 ---> classData
  • 方法区和堆一样,是各个内存共享的区域

  • 方法区在jvm启动时被创建,jvm关闭即释放,内存可以不连续

  • 方法区可以固定大小也可以动态扩展

  • 方法区的大小决定了系统可以保存多少个类,如果定义了太多的类,会OOM

  • jdk7之前使用 -XX:PermSize调整,默认 20M,之后使用 -XX:MetaspaceSize=100m(默认21m), -XX:MaxMetaspaceSize=100m 调整,默认 -1 即不限制。一旦触发初始高水位线就会触发full GC。

  • 如何解决OOM

    1. 首先dump内存快照导入内存分析工具
    2. 确认是出现了内存泄漏(memory Leak)还是内存溢出(memory overflow)
    3. 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,定位泄漏代码位置
    4. 如果不是内存泄漏,即内存中的对象确实还必须活着,就应该检查虚拟机的堆参数,对比物理内存看是否可以调大从代码上检查是否存在某些对象生命周期过长,持有状态时间过长的情况,尝试减少程序运行期的内存消耗
  • 方法区的内存结构

    graph
    subgraph 方法区
    类信息1
    类信息2
    ...
    运行时常量池
    end
    • 不同的jdk版本中,字符串常量存放的位置可能不同,有些是放在运行时常量池中
    • 方法区用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
      • 以下信息可以通过 javap -v Mytest.java 查看字节码文件(即方法区存储类信息)
      • 类型信息:对每个加载的类型(class、interface、enum、annotation),jvm必须在方法区存储一下类型信息
        1. 这个类型的完整有效名(全名=包名.类名)
        2. 这个类型直接父类的完整有效名称(对于interface或者Object都没有父类)
        3. 这个类型的修饰符(public、abstract‘final)的某个子集
        4. 这个类型直接接口的一个有序列表(一个类可以实现多个接口,将这些接口名称保存在一个列表)
      • 域(Field)信息(即属性)
        1. jvm必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
        2. 域的相关信息包括:域名称、域类型、域修饰符(public/private/protected/static/final/volatile/transient的某个子集)
      • 方法信息
        1. 方法名称
        2. 方法返回类型(包括void)
        3. 方法参数的数量和类型(按顺序)
          4.方法的修饰符(public/private/protected/static/final/synchronized/native/abstract)
        4. 方法的字节码、操作数栈、局部变量表及大小(abstract和native方法除外)
        5. 异常表(abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
    • 运行时常量池
      字节码文件中的常量池加载进方法区之后便称之为运行时常量池,每个类都会创建对应的运行时常量池

      • 运行时常量池包含多种不同的常量,包括编译时期就已经明确的数值字面量,也包括到运行期解析后才能获得的方法或者字段的引用,此时不在是常量池中的符号地址了,这里转换为真实的地址
      • 具有动态性,如本来常量池中没有某个字符串,但是可以通过 String.intern() 方法将其放入常量池
      • 为什么需要常量池?
        file
    • 方法区的演进细节
      1. 只有hotspot才有永久代
      2. file
      3. 为什么要用元空间替换永久代?
        类信息生命周期比较长,使用直接内存存放不容易出现OOM。堆永久代进行调优比较困难(fullGC判断比较困难)
      4. StringTable(字符串常量池)为什么要调整
        jdk7将其放入堆空间中,因为永久代的回收效率很低,在fullGC时才会触发,而fullGC是老年代或永久代空间不足才会触发,这就导致了StringTable的回收效率不高,而我们开发中会有大量字符串被创建,容易导致内存不足。
      5. 静态变量存放位置
        例:static int[] array = new int[1024]
        无论是jdk6、7、8,上述变量中的 new int[1024] 对象实体都是存放在堆空间的(事实上只要是对象实例一定是放在堆空间中),只是其变量名在不同版本中存放位置不同(见上)
        file
  • 方法区的垃圾收集

  • java虚拟机规范对方法区的约束非常宽松,可以不要求虚拟机在方法区实现垃圾收集
    一般来说,方法区的回收效果较差,尤其是类型的卸载,条件非常苛刻,但是这部分的区域回收有时又是有必要的

    • 方法区的垃圾收集主要回收两个部分内容:1)常量池中废弃的常量。 2)不再使用的类型
    • 判断一个常量是否废弃相对简单,但判断一个类型是否废弃相对困难,需同时满足以下三个条件才被允许回收,且不一定会回收
      • 该类的所有实例都被回收,也就是说java堆中不存在该类及其任何派生子类的实例
      • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI,JSP的重加载等,否则通常是很难达成的
      • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • 对象实例化的几种方法?

    1. new
    2. Class的newInstance():反射的方式,只能调用空参的构造器,必须为public
    3. Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没要求
    4. 使用clone():不调用任何构造器,需要实现Cloneable接口
    5. 使用反序列化
    6. 第三方库Objenesis
  • 对象创建的步骤

    1. 判断对象对应的类是否加载、链接、初始化
    2. 为对象分配内存
      • 如果内存规整—指针碰撞
      • 如果内存不规整—虚拟机维护一个空闲空间列表
    3. 处理并发的安全问题
      • CAS、区域加锁保证更新的原子性
      • TLAB
    4. 属性的默认初始化—所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
    5. 设置对象的对象头—将对象所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中,这个过程的具体设置方式取决于JVM实现
    6. 执行 init 方法进行显式初始化
  • 对象的内存布局

    graph LR
    layout[内存布局] ---> header[对象头]
    header ---> markWord[运行时元数据]
    header ---> 类型指针*指向元数据instanceClass确定该对象类型*
    markWord ---> hashcode
    markWord ---> GC分代年龄
    markWord ---> 锁状态标志
    markWord ---> 线程持有的锁
    markWord ---> 偏向锁ID
    markWord ---> 偏向时间戳
    layout ---> instance[实例数据*对象真正存储的有效信息=包括程序代码中定义的各种类型的字段=包括从父类继承下来的*]
    instance ---> role1[规则1=相同宽度的字段总是被分配在一起]
    instance ---> role2[规则2=父类中定义的变量会出现在字类之前]
    instance ---> role3[规则3=如果CompactFields参数为true*默认true*=字类的窄变量可能插入到父类变量的空隙]
    layout ---> padding[对齐填充*非必须=仅仅为占位符*]

图示:
file
file
file

  • 对象的访问定位

    • jvm是如何通过栈帧中的对象引用访问到其内部的对象实例的?
      1. 句柄访问(优点:对象移动后,栈空间引用的地址相对稳定)
        file
      2. 直接指针(hotspot采用,不用单独开辟和维护句柄池空间)
        file
  • 直接内存(元空间使用)

    • 最早在NIO中使用,通过 DirectBuffer 类操作直接内存
    • 通常来说,访问直接内存速度优于java堆
    • 也有可能OOM(direct buffer memory)
    • 缺点:1)分配回收成本高。2)不受JVM内存回收管理
    • 直接内存大小可以通过 MaxDirectMemorySize 设置,如果不指定,默认与堆最大值 -Xmx 参数一致

执行引擎

file

  • 执行引擎的任务就是 将字节码指令解释/编译为对应平台上的本地机器指令, 相当于将高级语言翻译为机器语言的翻译者。

  • 不断地执行翻译程序计数器中所指的指令(解释器执行情况)

  • 包含垃圾收集器、解释器和JIT(just in time)编译器(hotspot中两者并存,JRockit不包含解释器)

  • 为什么要保存解释器?

    • 当程序启动,解释器就能立即执行,省区许多不必要的编译时间,但随着时间的推移,编译器发挥越来越多的作用,以获得更高的执行效率。
    • 编译器进行激进优化不成立时,解释器可以成为逃生门
  • 何时使用JIT

    • 热点代码(hotspot采用的是基于计数器的热点探测,又有两种不同类型)
      • 方法调用计数器:用于统计方法的调用次数
        1. 阈值在Client模式下默认 1500 次,Server模式 10000次,-XX:CompileThreshold设置
        2. 热度衰减:超过一个半衰周期就将计数器减少一半。使用–XX:-UseCounterdecay 关闭热度衰减,-XX:CounterHalfLifeTime设置半衰周期
      • 回边计数器:用于统计循环体执行的循环次数
  • -Xint 完全采用解释器。-Xcomp 完全采用JIT,如果即时编译出现问题,自动使用解释器。-Xmixed 并存

  • JIT分类

    • hotspot内嵌两个JIT编译器,分别为Client compiler和Server compiler,简称C1、C2编译器,可以通过 -client 和 -server 切换,64位计算机默认使用 -server
    • C1优化策略:
      1. 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转的过程
      2. 去虚拟化:对唯一的实现类进行内联
      3. 冗余消除:在运行期间把一些不会执行
    • C2优化策略
      1. 标量替换:用标量值代替聚合对象属性值
      2. 栈上分配:对未逃逸的对象分配在栈而不是堆
      3. 同步消除:消除同步操作,通常指synchronized
    • 分层编译(C1、C2协调):java7之后 -server 默认使用该模式
    • Graal编译器:jdk10之后引入
  • AOT编译器(与JIT并列关系):JIT是在程序运行过程中将字节码转换为机器码,并部署至托管环境中的过程。而AOT编译是,在程序运行之前便将字节码转换为机器码的过程(例:使用编译工具jaotc将.class文件转换为.so文件存放至生成的动态共享库中)

    • 优点:减少第一次运行慢的不良体验
    • 缺点:1)破坏了依次编译,到处运行。2)降低了java链接过程的动态性,加载的代码在编译器就必须全部已知。

StringTable

  • String基本特性

    • final修饰
    • jdk9之前使用的是 char[],jdk9之后使用的是 byte[],原因:大部分String对象只包含拉丁文,而拉丁文只用一个byte即可保存,因此使用char(2 byte)会浪费一半的空间。在jdk9之后使用byte数组加上一个encoding-flag字段,该字段表示使用一个字节保存还是两个字节保存(如汉字就需要两个字节)
    • 不可变性:
      • 对字符串重新赋值时,需重新指定内存区域赋值,不能使用原有的value进行赋值
      • 当对现有字符串进行连接操作时,同上
      • 当调用string的replace时,同上
    • 通过字面量方式(区别于new,直接使用 ="abc")给一个字符串赋值,此时字符串值声明在字符串常量池中,字符串常量池中的字符串不会重复。
  • StringTable底层原理

    • String的string pool(字符串常量池)是一个固定大小的Hashtable,默认长度1009(jdk6为1009,jdk7之后默认为60013,1009为可设置的最小值)。如果放进string pool的string非常多就会造成hash冲突严重,从而导致链表很长,而链表变长的直接影响是当调用String.intern的性能大幅下降
    • 使用-XX:StrigTableSize设置stringtable的长度
  • String的内存分配

    • java中8种基本数据类型加上String,都提供了一种常量池的概念。
    • 常量池就类似于java系统级别提供的缓存,8种基本类型的常量池都是系统协调的,String的常量池比较特殊,主要有两种使用方法:
      1. 直接使用双引号定义的字符串直接放进常量池
      2. 使用String的intern()的方法将字符串放入常量池
    • java6及之前,字符串常量池放在永久代
    • java7之后将字符串常量池放入java堆中(jdk8之后元空间转到直接内存,但字符串常量池还是在堆中),因为永久代默认比较小,且永久代回收频率低
  • 字符串拼接操作

    • 使用+拼接的结果在常量池("abc" + "def"),原理是编译期优化,生成字节码文件时就直接拼接好
    • 如果拼接结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
    • 只要其中有一个是变量(string s = "abc"; s + "def"),结果就在堆中,变量拼接的原理是StringBilder。注:如果使用final修饰变量,仍然使用编译优化的方法
  • intern()的使用

    • 使用String的 equals 方法判断常量池中是否已存在该字符串
    • 如果项目中创建了大量重复的字符串,则可以通过 String str = new String("abc").intern()来创建,如果没有intern方法,虽然在字符串常量池中只会有一个"abc"实例,但在堆中会维护大量"abc"的实例。但使用intern方法之后,原本指向堆中的引用就会指向常量池,则大大节省内存。
  • new String到底创建了几个对象
    两个,堆中创建一个String对象,常量池中存放字符串(使用 ldc 指令),如果创建多个String("abc"),则会调用多次ldc指令,但是只会在常量池添加一个字符串
    Stringbuilder的tostring方法不会再字符串常量池中添加字符串(是否添加还需要看类的具体实现)
    file
    file
    file

  • G1垃圾收集器中的字符串去重操作

    String s1 = new String("abc");
    String s2 = new String("abc");

    正常情况下,会在堆中存放两个String对象,字符串常量池中存放一个"abc"对象。但G1会优化为只在堆中存放一个String,同时s1、s2同时指向该对象

垃圾回收

  • 基本数据类型(标量)没有回收行为

  • 垃圾回收相关算法

    • 标记阶段:引用计数法、可达性分析法
      1. java垃圾收集器没有使用引用计数法,主要是因为其无法解决循环引用的情况。python中使用了引用计数,使用 弱引用 和 在合适的情况下清除循环引用 来解决该问题
      2. 可达性分析(java、C#):
        • 以GC Roots为根节点,所有没有直接或间接与其相连的对象即为垃圾对象。从GC Root到对象的路径称为引用链(使用MAT或者JProfiler可查看GCRoots)
        • GC Roots(由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存中,那它就是一个Root)
          1. 虚拟机栈中引用的对象
          2. 本地方法栈中JNI(通常说的本地方法)引用的对象
          3. 方法区中类静态属性引用的对象
          4. 方法区中常量引用的对象
          5. 所有被同步锁synchronized持有的对象
          6. Java虚拟机内部的引用(基本数据类型对应的Class对象,一些常驻的异常对象如NullPointerException、OOM等,系统类加载器)
          7. 反映jvm内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
          8. 除了这些固定的GC Roots之外,根据用户选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整的GC Roots,如:分代收集和局部回收,再如,新生代中的对象可能被老年代引用
    • 清除阶段:
      file

      1. 标记-清除算法(Mark-Sweep)
        file

        • 两个阶段:
          • 标记:Collector从GCRoots开始遍历,标记所有被引用的对象,一般是在对象的Header中记录的可达对象
          • 清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有被标记,则将其回收
        • 缺点:1)效率不高。2)STW时间长。3)清理后的空间不连续,需要维护一个空闲列表
      2. 复制算法(Copying)
        file

      将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中存货的对象复制到未被使用的内存卡中,之后清理正在使用的内存块中所有的对象,交换两个内存的角色,完成垃圾收集。(Eden区使用该算法)

      • 优点:

        1. 实现简单,效率高
        2. 清除之后不会出现碎片问题
      • 缺点:

        1. 浪费一半空间
        2. 对于G1这种拆分成大量region的GC,复制而不是移动意味着GC需要维护region之间的引用关系,不管时内存占用还是时间开销都不小
        3. 如果存活的对象较多,则该算法不合适
          1. 标记-压缩算法(Mark-Compact):相当于在 标记-清除 之后执行一次内存整理
            file
      • 执行过程:

        1. 标记:同 标记-清除
        2. 压缩:将所有存活的对象压缩到内存的一端,按顺序排放。之后清理边界外的所有空间
      • 优点:不会产生内存碎片

      • 缺点:1)效率比标记-清除更低。2)移动对象的同时,需调整其被引用地址

        1. 分代收集算法
      • 依据每种收集算法的特点以及不同堆空间的特点选择不同的收集算法。

      • 年轻代:区域相对老年代较小、对象生命周期短、存活率低、回收频繁(使用复制算法最合适)

      • 老年代:区域较大、对象生命周期长、存活率高、回收不及年轻代频繁(意识 标记清除 或 标记整理 混合实现)

      • 在hotspot中的CMS(老年代收集器)使用的时标记清除算法,当回收效果不佳,碎片导致Concurrent Mode Failure时,自动采用Serial Old(使用标记整理算法)执行FullGC,

        1. 增量收集算法
          一次性将所有的垃圾进行处理,需要停顿较长时间(STW),我们可以让垃圾收集线程和应用程序线程交替执行,每次,垃圾收集线程只收集一小片区域的内存空间,然后切换到应用程序线程,依次反复,直至垃圾收集完成。其算法基础依旧时标记清除和复制算法。
      • 缺点:线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量下降。

        1. 分区算法
          将一块大的内存区域分割成若干个小的内存区域,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减小一次GC产生的STW。分代算法将按照对象的生命周期长短划分为两个部分(新生代、老年代)。分区算法将内存区域分为若干小的region
  • finalize方法
    使用该方法运行开发人员提供对象销毁之前的自定义处理逻辑,当回收某对象之前,总会先调用该对象的finalize方法,该方法运行被子类重新,用于在对象被回收是进行资源的释放,比如关闭文件、套接字和数据库连接等。该方法只会调用一次。

    • 该方法的执行时间是没有是没有保障的,完全由GC线程决定,极端情况下,若不发生GC,则该方法没有执行机会
    • 由于该方法的存在,虚拟机中的对象一般处于三种可能的状态:
      1. 可触及:从根节点开始,可以到达该对象
      2. 可复活:对象所有的引用都被释放,但是对象可能在finalize()中复活
      3. 不可触及:对象的finalize()被调用,并且没有复活
  • 判断一个对象是否可被回收,至少要进行两次标记

    1. 如果对象到GC Roots没有引用链,则进行第一次标记
    2. 进行筛选,判断该对象是否有必要执行finalize()方法
      1. 如果对象没有重写该方法,或该方法已经被虚拟机调用过,则该对象被判定为不可触及。
      2. 如果对象重写了该方法,且未被执行,则该对象会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize方法执行。
      3. 稍后GC会对F-Queue队列中的对象进行第二次标记,若重新建立了引用,则该对象被移出“即将回收对象集合”。之后,若对象再次出现没有引用的情况下,finalize方法不会再次执行,对象直接变为不仅触及状态。也就是说,一个对象的finalize方法只会被调用一次。
  • System.gc()

    • 实际调用的时Runtime.getRuntime().gc()
    • 触发的是Full GC
    • 无法保证对垃圾收集器的调用,不一定会调用,调用时间也不一定
  • 内存溢出(OOM):没有空闲内存,并且垃圾收集器也无法提供更多的内存

    1. jvm堆内存设置不够
    2. 代码创建了大量大对象,并且长时间不能被垃圾收集器收集
  • 内存泄漏(Memory Leak)
    严格来说,只有对象不会再被程序用到了,但是GC又不能回收它们的情况,才叫内存泄漏。宽泛来说,错误的编码对象的生命周期导致的OOM也能称为内存泄漏(比如应该是方法内定义的对象定义成了类属性)。循环引用可能造成内存泄漏,但是一般不用这个举例,因为java中没使用引用计数法。
    file

  • STW
    枚举GCRoots时,因为GCRoots在不断变化

  • 垃圾回收的并发和并行

    • 并行:多条垃圾收集线程并行工作,此时用户线程仍处于等待状态(ParNew/Parallel Scavenge/Parallel Old)
    • 并发:用户线程和垃圾收集线程同时执行,两者执行于不同的连个cpu核心中(CMS/G1)
  • 引用

    • 强引用(Strong Reference)
      类似 Object obj = new Object() 这种引用关系,只要强引用还在,则对象永远不会被回收
    • 软引用(Soft Reference)
      在系统将要发生内存溢出之前,会将这些对象列为回收范围之中进行二次回收,如果这次回收还是没有足够的内存,才会抛出OOM的异常。一般用于缓存
Object obj = new Object();  //声明强引用
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  //销毁强引用
- 弱引用(Weak Reference)
被弱引用关联的对象只能生存到下一次垃圾收集之前,当垃圾收集器工作时,无论内存空间是否足够,都会回收。一般用于缓存。 WeakHashMap<k,v>使用了弱引用
Object obj = new Object();  //声明强引用
WeakReference<Object> sf = new WeakReference<Object>(obj);
obj = null;  //销毁强引用
- 虚引用(Phantom Reference)
一个对象是否存在虚引用,完全不会对其生存时间构成影响,也无法通过虚引用获得一个对象实例。唯一目的就是能在该对象被回收时受到一个系统通知。一般用于对象回收过程追踪。创建虚引用后,试图使用get()获取时总是返回null
  • 垃圾回收器

    • 分类

      • 串行回收器:Serial、Serial Old
      • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
      • 并发回收器:CMS、G1

      file


      • 按线程数分,可以分为串行和并行收集器
        串行回收指的是在同一时间段内只允许有一个cpu用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。,client模式默认是串行回收
      • 按照工作模式,可以分为并发式和独占式
        并发式垃圾回收器与应用线程交替工作,尽量减少STW。独占式的一旦开始STW,则停止所有的用户线程
      • 按碎片处理方式,可以分为压缩式和非压缩式
        主要看收集完垃圾之后是否会进行内存整理
      • 按照工作内存区间分,有老年代和新生代两种
    • 吞吐量:运行用户代码的时间占总运行时间的比例
      总运行时间 = 程序的运行时间 + 内存回收时间

      • 比如jvm总共运行了100分钟,用户线程占了99分钟,垃圾回收占了1分钟,则吞吐量为 99%。
      • 吞吐量越高,在直觉上会觉得程序运行越快
    • 暂停时间:STW

      • 吞吐量与暂停时间矛盾,因为如果吞吐量优先,则必然需要降低内存回收频率,但是这样会导致GC需要更长时间的暂停。如果追求暂停时间,则会频繁地执行内存回收,导致吞吐量下降。所以一般根据实际情况,在二者之间找到一个平衡。现在的标准是:在最大吞吐量的情况下,尽量降低暂停时间。
    • 内存占用:Java堆所占内存大小

file

file

file

  • 为什么要有很多的收集器?
    因为java使用的场景很多,不同场景有不同的需求,需要选择最符合应用场景的收集器。

  • 如何查看默认的垃圾收集器?

    1. -XX:+printCommandLineFlags:查看命令行相关参数,其中包含垃圾收集器
    2. 使用命令行指令:jinfo -flag 相关垃圾收集器参数 进程ID
  • Seial回收器:串行回收,单线程,单核采用

    • file

    • 最基本、历史最悠久。

    • Hotspot中client模式下新生代默认的收集器,Serial Old是client模式下老年代的默认收集器。

    • 采用复制算法、串行回收和STW机制

    • Serial Old同样采用了串行回收和STW机制,只不过内存回收算法使用的是标记-压缩算法

    • Serial Old在server模式下主要有两个用途:1)与新生代Parallel Scavenge配合使用。2)作为老年代CMS收集器的后备垃圾收集器,CMS回收失败启用。

    • 优势:简单高效(与其他单线程比)。

    • 在hotspot中使用 -XX:+UseSerialGC 指定年轻代使用Serial且老年代使用Serial Old

  • ParNew:并行回收

    • file

    • ParNew则是Serial的多线程版本,除了其采用并行回收外,两者几乎没有任何区别,两者也共享了相当多的代码

    • 很多jvm运行在Server下默认的收集器

    • 多CPU的情况下ParNew比Serial更好,单CPU效率反而更低

    • 使用 -XX:+UseParNewGC 指定收集器,使用 -XX:ParallelGCThreads 限制线程数量,默认同CPU数量相同

  • Parallel Scavenge:吞吐量优先

    • 采用了复制算法、并行回收和STW机制
    • 与ParNew不同,它的目标是达到一个可控制的吞吐量
    • 不同于ParNew拥有自适应调节策略,运行过程中可以自动调整内存策略(见下)
    • 高吞吐量主要适合在后台运算而不需要太多交互的任务,例如执行批量处理、订单处理、科学计算等。
    • Parallel 收集器在jdk6时提供了老年代的Parallel Old用来替代Serial Old
    • Parallel Old采用了标记-压缩算法,同样基于并行回收和STW
    • jdk8中,两者配合是默认收集器
    • -XX:+UseParallelGC指定收集器 -XX:+UseParallelOldGC 指定老年代(这两者会互相激活对方)-XX:ParallelGCThreads 设置线程数 -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间 -XX:GCTimeRatio 垃圾收集时间占总时间比例,用于衡量吞吐量,与上一个参数有一定矛盾性,暂停时间越长,radio参数就越容易超过设定比例。-XX:+UseAdaptiveSizePolicy 设置是否使用自适应调节
    • 为了尽可能将停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整java堆大小或其他参数。如果设置的时间比较短,则会将堆空间减小,但是收集的频率增大了,进而吞吐量下降。
    • 自适应调节开启的话,会自动调整年轻代大小、eden和survivor比例、晋升老年代对象的年龄等。
  • CMS(Concurrent-Mark-Sweep):低延迟、jdk14之后被删除

    • file

    • 真正意义上的并发收集器,第一次实现了用户线程和垃圾收集线程同时工作。

    • 采用标记-清除算法,及STW

    • 只能和ParNew和Serial配合使用

    • 过程

      1. 初试标记
        仅仅标记出GCRoots能直接关联的对象,需要STW,时间较短
      2. 并发标记
        从GCRoots的直接关联对象开始遍历整个对象的过程,这个过程耗时较长,但是不需要停顿用户线程
      3. 重新标记
        修正并发标记期间,因用户线程继续运作而导致标记产生变动的那一部分对象,只重新标记2中被标记过的垃圾不确定的垃圾对象,不标记这个过程中新产生的垃圾对象。需要STW,但时间比较短
      4. 并发清除
        清理被标记判断已经死亡的对象,释放内存空间
    • 由于耗时的并发标记和并发清理阶段都不需要STW,所以整体的回收的低停顿的,另外,由于垃圾收集阶段用户线程并没有中断,所以CMS回收过程中还应该确保应用程序线程有足够的内存可用,因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值的时候(jdk6以上默认值92%)就开始回收,。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,然后启用后备收集器。

    • 弊端:1)会产生内存碎片,因为清除过程中用户线程也在工作,故不能对内存进行整理。但是可以使用参数设置指定多少次FullGC之后进行内存整理。2)对CPU资源非常敏感。在并发阶段,虽然不会导致用户挺短,但是会因为占用了一部分线程而导致用户程序变慢,总吞吐量会降低。3)无法收集浮动垃圾,无法标记在并发标记阶段产生的新的垃圾对象,导致这些新的垃圾对象没有及时被回收
      file

  • G1(Garbage first): 区域化分代
    官方给G1设定的目标是在延迟可控的情况下,获得尽可能高的吞吐量。主要针对配备多核CPU及大量内存的及其。

    • 为什么需要?

      1. 业务越来越庞大、复杂,用户越来越多
      2. 为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量
    • 基本原理?

      1. G1是一个并行回收器,将堆内存分割成很多不相关的区域(Region,物理上不连续)。使用不同的Region表示Eden、servoir0、servoir1、老年代等
      2. G1的GC有计划地避免在整个堆中进行全区域垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的经验值),在后台维护一个优先列表,每次根据允许收集时间,优先回收价值最大的Region
    • 使用 -XX:+UseG1GC开启,jdk9之后默认

    • 主要特征、优势

      1. 并行与并发
        • 并行:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,需STW
        • 并发:G1拥有与应用程序交替执行的能力,一般来说,不会在整个回收阶段完全阻塞应用程序的情况
      2. 分代收集

        • 从分代上看,G1依然署于分代型垃圾回收器,但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量
          file

        • 将堆空间分成若干个区域,这些区域中包含了逻辑上的年轻代和老年代。

        • 和之前的各类收集器不同,它同时兼顾老年代和年轻代,对比其他收集器,有的只能在年轻代有的只能在老年代。

      3. 空间整合
        • Region之间使用复制算法,但整体上实际可以看作 标记-压缩,两种算法都可以避免内存碎片
      4. 可预测的停顿时间
        • 这是G1相对于CMS的另一大优势,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒。
        • 由于分区的原因,G1可以只选择部分区域进行内存回收(不用全部回收新生代或老年代),这样缩小了回收范围,因此对于全局停顿情况的发生也能得到较好的控制
        • 根据后台维护的region回收的优先列表,每次根据允许的收集时间,有效收集价值比较大的region
          相比于CMS,G1未必能做到CMS在最好情况下的延时停顿,但最差的情况要好很多
    • 缺点
      相较于CMS,G1还不具备全方位、压倒性的优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用,还是运行时的额外执行负载都比CMS高
      从经验上来说,在小内存应用上CMS表现一般优于G1.平衡点大概在 6 – 8 GB之间。

    • 参数设置

      • -XX:+UseG1GC:手动指定使用G1
      • -XX: G1HeapRegionSize:设置每个Region大小,值为 2 的幂,范围是1 – 32MB,一般情况下,目标是根据最小的Java堆大小划分出2048个区域。默认是堆内存的 1/2000
      • -XX: MaxGCPauseMillis:设置预期达到的最大GC停顿时间,但不保证。默认 200ms
      • -XX:ParallelGCThread:设置STW时GC线程数的值,做多8
      • -XX:ConGCThreads:设置并发线程数
      • -XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值,超过此值就触发GC,默认45
    • 使用场景
      file

    • Region
      将整个堆划分为2048个大小相同的独立Region,每个Region大小根据堆内存实际大小而定,整体被控制在1-32MB之间,为2 的N次幂,即 1、2、4、8、16、32MB。所有的Region大小相同,且在jvm生命周期内不会改变
      虽然还保留有新生代和老年代的概念,但新生代和老年代不再时物理隔离的了,只是在逻辑上连续(见上图),每个Region代表的分区是动态的

      • Humongous区
        如果大对象大小超过1.5个region就放在Humongous区。
        对于堆中的大对象,默认直接分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾回收器造成负面影响。为了解决这个问题,G1划分除了Humongous区,专门存放大对象,如果一个H区都放不下,那么G1会寻找连续的H区来存储,为了能找到连续的H区,有时候不得不启动FullGC。G1的大多数行为都把H区作为老年代的一部分看待。
    • 回收过程

      graph LR
      YGC[年轻代GC] ---> YGC_CM[年轻代GC+并发标记过程] ---> MGC[混合回收] --->YGC
      MGC -.-> FGC[FullGC] -.->YGC

      file

      独占式:需要STW

    • Remebered Set
      file
      file

    • 年轻代GC
      file

      dirty card queue: 当老年代对象引用新生代对象时,不能直接更新RSet,因为这个操作需要加锁同步,耗费资源。所以先将引用关系记录在dirty card queue队列中

    • 并发标记过程
      file

    • 混合回收
      file
      file

    • FullGC(很少)
      G1的初衷就是要尽量避免FullGC,如果上述三个过程不能正常工作,G1会停止应用程序的执行,使用单线程进行垃圾回收

    • G1优化建议

      1. 年轻代大小
        • 避免使用 -Xmn或者-XX:NewRatio等相关选项显示设置年轻代大小
        • 固定年轻代的大小会覆盖暂停时间目标
      2. 暂停时间目标不要太过苛刻
        • G1 GC的吞吐量目标时90%的应用程序时间和10%的垃圾回收时间
        • 评估G1 GC的吞吐量,暂停时间目标不要太苛刻。目标太过苛刻会直接影响吞吐量
  • 7中垃圾回收器总结
    file

  • 如何选择垃圾回收器?

    1. 优先调整堆的大小让jvm自己选择适用
    2. 如果内存小于100M,使用串行收集器
    3. 如果时单核、单机程序,没有停顿时间的要求,使用串行收集器
    4. 如果是多CPU、看重吞吐量、允许停顿时间超过1秒,选择并行或者JVM自行选择
    5. 如果时多CPU、追求低停顿时间,需要快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器,官方推荐G1,性能高,现在的互联网项目基本都是G1
  • 日志常用参数
    file

  • 其他垃圾回收器(百度)
    Epsilon
    Shenandoah
    ZGC
    file

Class 文件结构

  • 前端编译器
    将java语言编译成字节码文件,默认使用的javac,tomcat使用ECJ编译jsp文件,同样还有 AspectJ、ajc等
    前端编译器并不会直接涉及编译优化等方面的计数,具体优化更多依赖于后端编译器(主要是JIT)

  • 字节码指令
    Jvm的指令是由一个字节长度的、代码着某种特定操作含义的操作码以及跟随其后零至多个代表此操作所需参数的操作数构成(可以使用javap查看)
    file

Class文件格式采用一种类似于C语言结构体的方式及进行存储,这种结构只有两种数据类型:无符号数和表(可以理解为数组)
file

- 无符号数署于基本数据类型,以 u1\u2\u4\u8(类比 int、long...)分别表示1、2、4、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF8编码构成的字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的符合数据类型,所有的表都习惯性以“_info”结尾。表用于描述有层次关系的符合结构数据,整个Class文件本质就是一张表。由于表没有固定长度,所以通常在其前面加上个数说明
  • Class文件结构
    file

    • 魔数

    • Class文件版本

      • 只能向下兼容
    • 常量池

      • 在版本号之后,紧跟着就是常量池的数量,以及若干个常量池表项
      • 常量池中的常量的数量是不固定的,所以常量池的入口需要放置一项u2类型的无符号数,代表常量池容量计数值。该容量计数从1开始
      • 常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
      • 常量池计数器比常量池表长度多1,因为它把第0项空出来了,这是为了满足后面某些常量池的索引值的数据在特定情况下不要表达“不引用任何一个常量池项目”的含义,这种情况下可以用索引值0来表示
      • 常量池主要存放两大类常量:字面量和符号引用。它包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项都具有相同的特征。第1个字节作为类型的标记,成为bag byte,如1表示CONSTANT_utf8_info,即utf8编码的字符串。3表示CONSTANT_inter_info即整型字面量等
        • 字面量:1)文本字符串。2)声明为final的常量值。如String str = "abc"中的abc,final int num=10中的10
        • 符号引用:1)类和接口的全限定名。2)字段的名称和描述符。3)方法的名称和描述符。例如:全类名为com.hunt.Test的全限定名为com/hunt/Test。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型及顺序)和返回值,基本数据类型及void都用一个大写字符表示,如B表示byte,C表示char,而对象类型则用符号“L”+对象全限定名 来表示,[表数组
        • 虚拟机只有在加载class文件到内存的时候才会进行动态链接,虚拟机在运行时,需要从常量池中获得对应的符号引用(与内存无关),再在类加载过程中的解析阶段将其替换为直接引用(与内存相关),并翻译到具体的内存地址中
        • 根据 常量类型和结构 表在字节码中进行解析,解析之后就是常量池部分对应的部分,该表反应了常量池表项中每个元素在字节码文件中所占的长度,因为字节码中没有分隔符,所以需要该表进行解析
          file
    • 访问标志

      • 使用两个字节表示(u2),用于识别一些类或接口层次的访问信息,包括i:这个class是类还是接口,是否定义为public、abstract类型,如果是类的话,是否被声明为final,类的访问权限通常为ACC_开头
        file

      • 带有ACC_INTERFACE标志的class文件表示的是接口而不是类,繁殖则表示类而不是接口

      • 如果一个class文件被设置了ACC_INTERFACE,那么同时也得设置ACC_ABSTRACT,同时不能设置ACC_FINAL、ACC_SUPPER、ACC_ENUM

      • 如果没有设置ACC_INTERFACE,则该class可以具有上表中除了ACC_ANNOTATION之外所有的标志。当然 ACC_FINAL和ACC_ABSTRACT互斥,不能同时拥有

      • ACC_SUPER标志用于确定类或接口里面的invokespecial指令执行的是哪一种语义,针对jvm指令集的编译器都应当设置这个标志,对于java8及以后版本来说,无论class文件中这个标志的实际值是什么,也不管class文件的版本号是多少,jvm都认为每个class文件均设置了ACC_SUPER标志

      • ACC_SYNTHETIC标志意味着该类或接口时由编译器生成的,而不是源码

      • 注解类型必须设置ACC_ANNOTATION、ACC_INTERFACE

      • ACC_ENMU表明该类或其父类为枚举类型
        file
        之所以是 21 表示 ACC_SUPPER(20) + ACC_PUBLIC(1)。 见上表

    • 类索引、父类索引、接口索引集合
      file

    • 字段表集合

      • 用于描述接口或类中申明的变量。字段(field)包括类级别变量以及实例级变量,但不包括方法内部、代码块内部声明的局部变量

      • 字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述

      • 它指向常量池索引集合,描述了每个字段的完整信息。比如 字段的标识符,访问修饰符,static修饰符,final修饰符等

      • 字段表集合不会列出从父类或接口中继承而来的字段,但有可能列出原本java代码中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段

      • 在java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须是否不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那么两个字段重名就是合法的

      • fields_count(字段表计数器)使用两个字节表示

      • fields表中每个成员都是一个field_info结构,用于表示该类或接口所声明的所有类字段或实例字段,不包括方法内部声明的变量也不包括从父类或接口继承的字段

      • fields_info包含如下信息,这些信息中,各个修饰符都是布尔值,要么有,要么没有

        1. 作用域(public、private、protected)
        2. 是实例变量还是类变量(static修饰符)
        3. 可变性(final)
        4. 并发可见性(volatile修饰符,是否强制从主内存读写)
        5. 是否可序列化(transient修饰符)
        6. 字段数据类型(基本数据类型、对象、数组)
        7. 字段名称
          file
      • 属性表集合(区别于字段表):一个字段还可能拥有一些属性,用于存储更多的额外信息。比如 final修饰的常量的初始化值、注释信息等。属性个数存放在 attribu_count中,属性具体内容存放在attributes数组中

    • 方法表集合

      • 只描述当前类或接口中声明的方法,不包含父类或接口中继承的方法,同时也可能出现代码中没有的方法,如()和()
      • 在java中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合。也就是因为返回值不会包含在特征签名之中,因此java语言无法仅仅依靠返回值的不同来对一个已有的方法进行重载,但在class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法由相同的名称和特征签名,但返回值不同,那么也是可以合法并存于一个class文件中。即尽管java语言规范并不允许在一个类或接口中声明多个方法签名相同的方法,但字节码中却恰恰运行存放多个方法签名相同的方法,唯一的条件就是这些方法的返回值不能相同。
      • 方法也有属性表集合,保存方法的指令集(Code属性,也有属性表集合)、注释信息等
        file
    • 属性表集合

      • 描述的是class文件所携带的辅助信息,比如该class文件的源文件名称,以及任何带有RetentionPolicy.CLASS 或者RetentionPolicy.RUNTIME的注解,这类信息通常被用于jvm的验证和运行记忆java程序的调试。
      • 区别于字段表、方法表的属性表集合

字节码指令集

  • java字节码对于虚拟机就相当于汇编语言对于计算机,属于基本执行指令
  • jvm指令由一个字节长度、代表着某种特定操作含义的数字(Opcode)以及紧随其后的零到倒戈操作数(Operands)构成。由于jvm采用操作数栈而不是寄存器架构,所以大多数指令都不包含操作数,只有一个操作码。
  • 由于一个指令只能由一个字节,所以操作码总数不能超过256条
  • 反编译的字节码中看到的iload/fload…等指令只是助记符,实际上指令只是一个数字。与助记符一一对应。
  • 大多数指令都包含了其操作所对应的数据类型信息,例如:iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是fload类型的数据。也有一些没有明确指定操作类型的字母,如arraylength指令,但其操作数永远只能时一个数组类型的对象。大部分指令都没有支持整数类型byte、char、short、boolean,编译器会在编译期间或运行期间将byte、short带符号扩展为相应的int(有正负),而boolean、char将零位扩展为相应的int(无符号),将他们作int处理。

  • 加载与存储指令
    加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。

    • 将局部变量加载到操作数栈:xload(其中x为i、l、f、d、a(引用类型))
    • 变量出栈入栈指令:xload
    • 常量入栈指令:const系列、push系列、ldc系列(三者操作可入栈数范围越来越大,比如const只能操作-1 – 5的数,push能操作-128 – 32767,ldc能操作八位的数)
    • 出栈装入局部变量表指令:xstore
    • 算数指令:用于对两个操作数栈上的值进行某种运算,并将结果入栈。加减乘除求余(rem)取反(neg)自增(inc)位运算/比较(cmpl)等
    • 类型转换指令:将两种不同的数值类型相互转换(除boolean),一般用于实现代码中的显示类型转换操作(强转),或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
      宽化类型转换:大范围类型转到小范围类型(例:int —> long —>float —> double),不需要显示转换,但仍会使用转换指令。float虽然为四个字节但是因为其分两部分,一部分保存数字,一部分保存指数,所以其表示范围很大。
      窄化类型转换:int —> byte/short/char,long —> int,需要显示强转
    • 对象的创建与访问指令:可进一步分为创建指令、字段访问指令、数组操作指令、检查指令
      创建类实例指令:new,接受一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压栈
      创建数组指令:newarray(基本类型数组)、anewarray(引用类型数组)、multianewarray(多维数组
      字段访问指令:1)访问类字段(static字段):getstatic、putstatic。2)访问实例字段:getfield、putfield
      类型检查指令:instanceof(判断给定对象是否为某一个类的实例,它会将判断结果压栈)、checkcast(检查类型强转是否可行,如果可行,那么该指令不会改变操作数栈,否则抛classcastexception)
    • 方法调用指令
      invokevirtual:调用对象的实例方法,根据对象的实际类型进行分派,支持多态。这是java中最常见的方法分派方式
      invokeinterface:调用接口方法,它会在运行时搜索由特定对象所实现的这个接口的方法,并找出合适的方法进行调用
      invakespecial:用于调用一些特殊处理的实例方法,包括构造器、私有方法、父类方法,这些方法都是静态类型绑定的(不能被重写,例如:public方法可能被字类重写,将使用invokevirtual),不会再调用时进行动态分派
      invokestatic:调用类方法(static),这是静态绑定的。接口中的静态方法调用也是该指令
      invokedynamic:调用动态绑定方法,jdk1.7后添加
    • 方法返回指令:xreturn,将当前函数操作数栈的顶层元素弹出,并将该元素压入调用者函数的操作数栈,如果当前返回的是synchronized方法,还会执行一个隐含的monitoreexit指令,退出临界区。
    • 操作数栈管理指令:pop(弹出)、dup(复制,new对象时可能用到,先在堆中开辟对象空间,将引用地址压栈,再调用dup,然后调用方法初始化对象,此时调用了构造器,使用了invockSpecial指令,该指令会需要使用地址一次,然后使用store指令将地址赋给局部变量表)等
    • 控制转移指令:1)比较 xcmpg。2)条件跳转 ifeq…。3)比较条件跳转(类似于比较指令和条件跳转指令的结合) if_icmpeq。4)多条件分支跳转 tableswitch/lookupswitch。5)无条件跳转 goto
    • 异常处理指令:
      athrow 显示抛出异常(throw语句),jvm自己抛异常使用idiv指令。
      处理异常:如果一个方法定义了一个try-catch或者try-finally的异常处理,就会创建一个异常表,它包含了每个异常处理或者finally块的信息,异常表保存了每个异常处理信息,比如 1)起始位置。2)结束位置。3)程序计数器记录的代码处理的便宜地址。4)被捕获的异常再常量池中的索引。
      当一个异常被抛出时,jvm会再当前的方法例寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧。,并且异常会重新抛给上层调用者。如果所有栈帧都弹出仍没有合适的异常处理,这个线程将终止。如果异常在最后一个非守护线程中抛出,则jvm终止。
      不管什么时候抛出异常,若异常处理最终匹配了所有的异常种类,代码就会继续执行,这种情况下,如果方法结束后没有抛出异常,仍然指向finally,在return前,它直接跳转到finally块完成目标

类的生命周期

  • 在java中数据类型分为两种:基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用类型则需要进行类的加载
graph LR
loading[加载]--->verification[验证]
subgraph linking
verification--->preparation[准备]
preparation--->resolution[解析]
end
resolution--->initialization[初始化]
initialization--->using[使用]
using--->unloading[卸载]

其中,验证、准备、解析三个部分统称为链接

loading
  • 通过全类名将java类的字节码文件加载到机器内存中,并在内存中构建出java类的原型——类模板对象。所谓类模板对象,其实就是java类在jvm内存中的一个快照,将解析出的常量池、类字段、类方法等信息存储到方法区(注:此时只是类的二进制数据结构,真正使用classLoader加载的Class对象(区别于实例对象)是存放在堆当中的)。反射的基础。
  • 数组类的加载:数组类本身并不是由类加载器负责创建,而是由jvm在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。
Linking
Verification
  • 目的是保证加载的字节码是合法、正确的、符合规范的。
  • 格式检查(魔数/版本/长度)、语义检查(是否一些final被重写了等)、字节码验证(试图通过对字节码流的分析,判断字节码是否能正确执行。比如变量的赋值是否给了正确的数据类型等,还有栈映射帧(StackMapTable)自行百度)、符号引用验证(解析时候才会执行,虚拟机会检查这些类和方法是否真实存在)
Preparation
  • 简而言之,为类的静态变量分配内存,并将其初始化为默认值
  • 不包括被static final修饰的基本数据类型字段,因为final在编译时期就已经分配了,在准备阶段会显示赋值
  • 注意,这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象一起分配到堆中
  • 在这个阶段并不会像初始化阶段那样会有初始化或者代码被执行
Resolution
  • 简而言之,将符号引用转换为直接引用的过程
  • 以方法为例:jvm会为每一个类准备一张方法表,将器所有的方法都放在表中,当需要调用一个类的方法时,只要知道这个方法在该方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表的位置,从而使得方法被成功调用
Initialization
  • 类装载的最后一个阶段(只有到了初始化阶段,才正则开始执行类中定义的java代码,如static代码块),执行类的初始化方法 (),该方法时由静态成员的赋值语句以及static语句块合并产生的。
  • 父类的总是在字类的之前调用,也就是说父类的static块优先执行。
  • 编译器并不会为所有的类都产生()方法
    1. 对于只有非静态的字段,不管是否进行了显示赋值,都不会产生
    2. 只有静态的字段,没有显示的赋值,也不会产生 public static int num;
    3. 只有静态基本类型常量也不会产生 public static final int num=1(准备阶段已经赋值);静态引用类型(除了直接赋值的String常量)在初始化阶段赋值
  • ()线程安全问题
    – 对于()方法的调用,即类的初始化,虚拟机会在内部确保其多线程环境下的安全性
    – 虚拟机会保证一个类的()方法在多线程环境中被正确的加锁、通过。如果多个线程同时初始化一个类,那么只会有一个线程去执行()方法,其他线程都需要阻塞等待。
    – 正是因为()带锁线程安全,所以如果一个类()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。例如A类加载时需要加载B类,B类加载时会加载A类,此时,使用两个线程分别加载A、B则可能发生死锁
  • 只有类被主动使用时才会调用类的<clinit>()方法,被动使用并不会引起类的初始化
    file

    • 被动使用(不会调用初始化并不意味着不会加载,即加载了一个类不一定会初始化才能使用)
      1. 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。如,当通过子类引用父类的静态变量时,不会导致子类的初始化
      2. 通过数组定义类引用,不会触发此类的初始化
      3. 引用常量不会触发此类或接口的初始化,因为常量在链接阶段就已经被显示赋值了
      4. 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化(区别于class.forname()方法)
Using
  • 区别于初始化过程中的 主动使用
Unloading
  • 回顾方法区的垃圾回收
  • 启动类加载器(bootstrap)的类型在jvm运行期间不可被卸载
  • 被系统类加载器和扩展类加载器加载的类型在运行期间也不太可能被卸载
  • 被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才能做到

类加载器

ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个于目标类对应的java.lang.Class对象实例。然后交给jvm进行链接、初始化等操作,因此ClassLoader在整个装载阶段,只能影响到类的加载阶段。

  • 类的唯一性
    对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在jvm中的唯一性。每一个类加载器都由一个独立的类名称空间。比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。如:new 两个相同的类加载器去加载同一个class文件,得到的就是不同的两个class
graph TD
UC1[User ClassLoader1] ---> AC[Application ClassLoader]
UC2[User ClassLoader2] ---> AC[Application ClassLoader]
AC ---> EC[Extension ClassLoader] ---> BC[Bootstrap ClassLoader]

除了引导类加载器,其他的也成为自定义加载器。上图并非继承关系,只是包含关系,即下层加载器(图中的上面)中包含对上层加载器的引用。

file

引导类加载器
  • 使用c/c++实现,嵌套在jvm内部
  • 它用来加载java的核心类库(JAVA_HOME/jre/lib/rt.jar或sun.boot.class.path路径下的类)
  • 并不继承自java.lang.ClassLoader,没有父加载器
  • 出于安全考虑,其只能加载包名为java、javax、sun等开头的类
  • 扩展类加载器和应用程序类加载器也是由其加载,并指定为他们的父加载器
  • 获取不到,一般返回null
扩展类加载器
  • java语言编写
  • 继承于ClassLoader类
  • 父类加载器为启动类加载器
  • 加载从java.ext.dirs系统属性所指定的目录中加载类库,或冲JDK安装目录的jre/lib/ext子目录下加载类库。如果用户将自己创建的jar放在该目录下,也会自动由其加载
应用程序加载器(系统类加载器)
  • java语言编写
  • 继承自ClassLoader
  • 父类加载器为扩展类加载器
  • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 应用程序中的类加载器默认是系统类加载器
  • 它是用户自定义类加载器的默认父加载器
  • 通过Class的getSystemClassLoader()方法可以获取该类加载器
用户自定义类加载器
  • 可以用来实现类库的动态加载,加载源可以是本地jar包,也可以是网络上的资源
  • 通过类加载器可以实现非常绝妙的插件机制。如OSGI组件框架,Eclipse的插件机制
  • 能够实现应用隔离。例如Tomcat、Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。
  • 通常需要继承于ClassLoader

数组类型的加载,数组类Class不需要加载器加载,只是在使用时才创建。使用的类加载器与数组元素的类加载器相同。如果是基本数据类型数组或void 如int[]则获取其class返回null,此时该null并不表示启动类加载器,而表示不需要类加载器。因为基本数据类型不需要加载,在jvm启动时已经预设了。

ClassLoader
  • loadClass(String name, boolean resolve)方法
    • resolve: 加载class的同时是否进行解析操作
    • 该方法的逻辑是实现双亲委派模型
    • 如果存在父类加载器,则使用父类加载器进行加载
    • 最终调用findClass()方法进行加载,一般只重新findClass而不重写loadClass,因为loadClass实现了双亲委派,一般不需要破坏该机制
  • findClass(String name)
    • 查找二进制名称为name的类,返回结果为java.lang.Class类的实例
    • 一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换为流,然后调用defineClass方法生成类的Class对象。
  • defineClass(String name, byte[] b, int off , int len)
    • 根据给定的字节数组b转换为Class实例
    • defineClass方法通常与findClass方法一起使用。一般情况下,覆盖findClass方法后应调用defineClass方法生成Class对象
    • 内部调用了preDefineClass()方法用于检查核心类库是否会被篡改,保护其安全
      file
SecureClassLoader 与 URLClassLoader
  • 扩展类加载器和应用程序类加载器继承自URLClassLoader
  • URLClassLoader 继承自 SecureClassLoader 并重写findClass方法
  • SecureClassLoader 继承自 抽象类 ClassLoader
  • 自定义类加载器一般继承两者之一,没有特殊要求继承 URLClassLoader 即可
Class.forName() 与 ClassLoader.loadClass()
  • Class.forName(String name):是一个静态方法,根据传入的类的全限定名返回一个Class对象。该方法在将Class文件加载进内存同时会执行类的初始化(主动使用)
  • ClassLoader.loadClass():实例方法,需要一个ClassLoader对象来调用该方法。该方法将Class文件加载进内存时不会执行初始化阶段,知道这个类被第一次使用才进行初始化(被动使用)
双亲委派机制

从jdk1.2开始使用。如果一个类加载器在接到加载类的请求时,首先将该请求委托给父类加载器完成,依次递归。只有父类加载器无法完成时,自己才去加载。jvm规范只是建议使用该方式

优势
- 避免类的重复加载,确保一个类全局唯一。
- 保护核心类库的安全,防止被篡改。例如自己实现一个java.lang.String,加载时就会报错
代码实现

file

弊端
  • 顶层的ClassLoader无法访问底层ClassLoader所加载的类
破坏机制的三次行为
  • jdk1.2之前没有双亲委派机制
  • 线程上下文类加载器
  • 用户对代码的动态性追求导致,如 代码的热替换、模块的热部署等
沙箱安全机制

自行百度

  • 为什么要自定义类加载器
    • 隔离加载类
    • 修改类加载方式
    • 扩展加载源
    • 防止源码泄漏
java9新特性

因为模块化的引入,导致类加载器有了较大变化。但为了保证兼容性,其并没有从根本上改变三层类加载器架构和双亲委派模型

  • 扩展机制被一尺,扩展类加载器由于向后兼容的原因被保留,不过被重命名为平台类加载器(platformClassLoader)。可以通过ClassLoader的新方法getPlatformClassLoader()来获取。jdk9时基于模块化构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件)。其中java类库就已天然地满足了可扩展的需求,自然无需保留\lib\ext 目录。此前使用这个目录或者 java.ext.dirs系统变量来扩展JDK功能的机制已经没有存在的价值了
  • 平台类加载器和应用程序类加载器不再继承自URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全部继承于jdk.internal.loader.BuiltinClassLoader。如果在jdk9以上版本以来了URLClassLoader类的特定方法,则代码会崩溃
  • 在jdk9中,类加载器可以指定名称。可以通过getName()来获取
  • 启动类加载器现在在jvm内部和java类库共同协作实现类加载器(以前是C++实现),但为了兼容,获取时仍返回null。
  • 类加载的委派关系也发生了变动。当平台即应用程序类加载器收到类加载请求时,在委派给父加载器之前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这种归属关系,就要优先委派给负责那么模块的加载器完成加载
    file

性能监控与调优

性能调优三步:发现问题(性能监控)、排查问题(性能分析)、解决问题(性能调优)

  1. 性能监控
    出现的问题可能有:GC频繁、cpu load过高、OOM、内存泄漏、死锁、程序响应时间较长等

  2. 性能分析
    实施方式可能有:打印GC日志,通过GCViewer等工具分析查看、使用命令行工具 jstack/jmap/jinfo等、dump堆文件,使用内存分析工具分析、使用阿里的Arthas或jconsole/JVisualVM实时查看jvm状态

  3. 性能调优
    方式:适当增加内存、选择合适的垃圾回收器、优化代码、增加机器、合理设置线程池线程数量、使用中间件如缓存,消息队列等

性能测试指标

  1. 停顿时间(响应时间)
    提交请求与返回该请求之间使用的时间或执行垃圾收集时程序的工作线程被暂停的时间
  2. 吞吐量
  3. 并发数
  4. 内存占用

监控工具

命令行工具

file

jps:查看正在运行的Java进程

file

参数:
-l 命令显示主程序全类名
-m 输出传递给main函数的参数
-v 显示启动虚拟机手动指定的参数 如-Xms20m

注:如果java进程使用了参数 -XX:-UsePerfData,那么jps和jstat命令将无法获得该进程
可以连接远程主机

jstat:查看jvm的统计信息

可以显示本地或远程jvm进程中的类装载、内存、垃圾收集、JIT编译等运行数据
file

例如:查看进程18996的堆相关(包括Eden、两个S区、老年代、永久代等)信息并每秒打印一次,共打印十次

jstat -gc 18996 1000 10
file

或者若只关心各个区域已使用内存占比的情况

jstat -gcutil 18996 1000 10
file

使用 -t 参数可以计算出GC时间占比

jinfo:查看虚拟机配置参数信息,也可以用于调整参数

只能修改部分被标记为manageable的参数,并立即生效

查看层级赋值的参数

jinfo -flags PID
file

jmap:导出内存映像文件和内存使用情况

-dump:生成堆快照(可手动导出,也可以使用参数自动触发导出)
file
一般使用 -dump:live 只生成存活对象的快照

-heap:输出整个堆的详细信息,包括GC的使用、堆配置信息、内存使用信息等
file

-histo:输出堆中对象的统计信息,包括类、实例数量和合计容量

注:jmap只会在安全点才会执行,否则阻塞等待
dump文件可以使用jhat命令查看(jdk9之后移除,使用virtualVM代替),会启动一个httpserver在浏览器中查看信息。一般不在生产环境直接使用,会占用较高的CPU

jstack:打印JVM线程快照(虚拟机堆栈跟踪)

线程快照就是当前虚拟机内指定进程的每一条线程正在执行的方法堆栈集合。作用:用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致长十间等待等问题。
file

jcmd:可以实现除了jstat之外所有命令的功能

可以使用 jcmd PID help 查看针对该进程可执行的所有指令

jstatd:远程主机信息的收集
图形工具
jconsole(自带)
Visual VM(自带):可用于取代 jconsole

最大的特点是支持插件扩展。

JMC(自带)

优点:取样分析,而不是传统的代码植入,对项目性能影响小

MAT(Eclipse插件)

memory analisy tool内存分析工具,也可以单独下载使用。主要用于分析dump文件(hprof文件或phd文件)
最大特点是可以生产内存泄漏报表

JProfiler

收费,更强大,使用方便,界面友好

Arthas(阿尔萨斯)

阿里巴巴开发,在线排查,无需重启,动态追踪代码,实时监控jvm状态

Btrace

浅堆与深堆(对应浅拷贝深拷贝)
浅堆(Shallow Heap)

指一个对象所消耗的内存。在32位系统中,一个对象引用会占据4字节,一个int类型会占据4字节,long类型占据8字节,每个对象头占据8字节。根据堆快照格式笔筒,对象的大小可能会向8字节对齐。
以String类型为例,其内部有三个属性,int hash32,int hash,ref value:2个int值共占8字节,ref对象引用占用4字节,String对象头占用8字节,合计20字节,向8字节对齐,占24字节(jdk7).这24字节位String对象的浅堆大小,它与String的value实际值无关,无论字符串长度如何,浅堆大小始终是24字节

深堆(Retained Heap)
  • 保留集(Retained Set)
    对象A的保留集是指当对象A被垃圾回收后,,可以被释放的所有的对象集合(包括对象A本身)。即对象A的保留集可以被认为是只能通过对象A被直接或间接访问到的所有对象的集合。通俗的说,即仅被对象A所持有的对象的集合

  • 深堆
    指对象的保留集中所有对象的浅堆大小之和

注:浅堆指对象本身占用的内存,不包括其内部引用对象的大小。一个对象的深堆指只能通过该对象访问到(直接或间接)的所有对象的浅堆之和,即对象被回收后,可以释放的真实空间。
因为对象的层层引用最终都归结于基本数据类型,所以只计算浅堆之和即可
引入概念:支配树

内存泄漏
与内存溢出的区别

严格来说jvm误以为某个对象还在引用,无法回收(占着茅坑不拉屎)
内存溢出:申请内存时,没有足够的内存可用
内存泄漏会导致内存溢出

内存泄漏的8种情况
  1. 静态集合类
    file

  2. 单例模式
    file

  3. 内部类持有外部类
    file

  4. 各种连接,如数据库连接、网络连接等
    file

  5. 变量不合理的作用域
    file

  6. 改变hash值
    file

  7. 缓存泄漏
    file

  8. 监听器和回调
    file

jvm运行时参数

GC日志

Leave a Comment