概述

对《深入Java虚拟机第三版》书籍的概括和总结,方便以后的回顾和查看

JVM运行时数据(重要)

  • 线程共享
    • 堆:存放Java对象
    • 元空间:存放编译后的常量、静态变量、类信息等
  • 线程私有
    • 虚拟机栈:存放普通方法调用的栈帧
    • 本地方法栈:存放native方法调用的栈帧
    • 程序计数器:存放下一个要执行的字节码指令
  • 直接内存:用于NIO相关的存储,减少从用户态到内核态的转换

栈帧组成:

  • 局部变量表:是一个数组,存放方法中用到的局部变量(一般索引第一个位置存放this引用,用于访问成员变量)
  • 操作数栈:存放字节码指令所需要的操作数
  • 动态链接:指向常量池中该栈所属方法的符号引用,用于方法调用过程中的动态链接
  • 方法出口:方法返回的地址

HotSpot虚拟机

对象创建过程

  • 当遇到new指令后,先进行类加载检查(检查常量池中是否存在),不存在,则加载类
  • 分配内存空间(两种方式)
    • 指针碰撞:把指令往空闲方向移动即可分配(需要压缩整理内存以保证规整)
    • 空闲列表:内部维护一个列表,记录哪些内存可用
  • 变量初始化零值(保证不赋初始值就可以使用)
  • 设置对象头信息(指向具体实例,元数据,hash值,GC分代年龄)
  • 调用类的构造方法

当使用带有压缩整理的垃圾收集器如Serial、ParNew时使用指针碰撞的方式分配内存

当使用基于清除算法的收集器如CMS时使用空闲列表的方式分配内存

对象的内存布局(重要)

  • 对象头
    • MarkWord:存储GC分代年龄,锁状态标志,线程id等信息
    • 指针类型:存储对象类型和指向的对象地址
  • 实例数据:对象数据
  • 对齐填充:对象的起始地址必须是8字节的整数倍

HotSpot虚拟机对象头MarkWord内容如下表所示:

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
偏向线程ID、偏向时间戳、对象分代年龄 01 偏向锁
指向锁记录的指针 00 轻量级锁
指向重量级锁的指针 10 重量级锁
空,不需要记录信息 11 GC标记

如何访问对象

共两种方式:

  • 句柄访问
    • 堆中划分出一个句柄池,reference就存储对象句柄,句柄池存放指向对象的数据
  • 直接指针访问(HotSpot采用)
    • reference中存储的直接就是对象地址

内存溢出实战

jdk版本使用的默认收集器

  • jdk8默认使用ParNew和Serial Old收集器
  • jdk9默认使用G1收集器

jvm常用参数

  • -Xms20M:设置堆的初始内存为20M
  • -Xmx20M:设置堆的最大内存为20M
  • -Xmn10M:设置年轻代内存为10M
  • -XX:+PrintGCDetails:输出gc详情日志
  • -XX:+HeapDumpOnOutOfMemoryError:当发生OOM时,自动生成堆内存快照

内存溢出分析

  • 堆内存溢出:HeapOverflow
    • 如果不是内存泄漏问题,设置一下堆内存大小即可
    • 如果是内存溢出问题,通过堆内存快照分析,并结合代码进行排查,查看泄漏对象到GC Roots的引用链,分析为什么没有被垃圾回收
  • 虚拟机栈内存溢出:StackOverflow
    • 可能是方法调用过深导致栈溢出,也可能存在不正常的递归调用问题(需要排查)。正常的调用过深可以设置栈空间大小来避免栈溢出
    • 如果栈内存允许动态扩展,当无法再申请到内存时会抛出异常
  • 元空间内存溢出
    • 在java8之后,将元空间移动到堆内存,默认没有大小限制,除非超出了堆内存限制
  • 直接内存溢出
    • 直接内存溢出一般看不到明显的异常情况(堆快照很小),考虑是否程序使用了DirectMemory(直接内存),例如大量使用了NIO,然后进行排查

垃圾收集(重要)

如何判断垃圾

  • 引用计数法

    高效,但不能解决循环引用的问题

    • 对象中添加引用计数器,被引用则计数器加1,如果计数器为0,则为垃圾
  • 可达性分析

    能够解决循环引用的问题,但是需要暂停用户线程

    • 通过GCRoots出发,根据引用关系搜索,如果对象到GCRoots的引用链断开(即不可达),则为垃圾

GC Roots

  • 堆内存
  • 虚拟机栈(栈帧中的局部变量表)
  • 本地方法栈
  • 元空间
  • ...

引用分类

  • 强引用:对象引用,如果引用关系存在,则不会回收
  • 软引用:非必须的对象,当内存溢出时,会对软引用进行回收(总共会回收两次,第一次标记,第二次回收)
  • 弱引用:非必须对象,一旦发生垃圾收集,则弱引用就会被收回
  • 虚引用:最弱的引用关系,只是为了对象被垃圾收集时收到一个通知

回收步骤

垃圾对象经过两次标记之后才能够被回收

  • 对象第一次被认定为垃圾后,进行第一次标记
  • 又一次垃圾回收时,判断标记的对象是否需要执行对象的finalize方法,如果没有或者已经执行过,则进行第二次标记
  • 当收集器看到经过两次标记的对象时,则回收该对象

回收算法

三大基本算法+分代理论+G1局部收集算法

基础回收算法

  • 标记-清除算法

    先标记垃圾,再清除

    • 会产生内存碎片
  • 标记-复制算法

    将内存空间分为相等两份,每次只使用其中一块,先标记垃圾,再将存活对象复制到另一份中,接着清空已经使用的内存

    • 内存利用率不高
    • 当对象的存活率很高时,算法效率不高
  • 标记-整理算法

    先标记并清除垃圾,然后整理还存活的对象(压缩内存)

    • 需要移动对象,维护对象引用,需要暂停用户线程才能进行

分代收集理论

  1. 绝大多数对象都是朝生夕死的
  2. 熬过垃圾收集次数越多的对象越不容易消亡
  3. 跨代引用相对于同代引用仅占极少数

分代年龄回收算法:根据分代收集理论,并结合对象存活的特点,因地制宜的使用基础回收算法

  • 新生代基本采用标记-复制算法(因为新生代的对象存活率很低)
    • 新生代又分为一个Eden区和两个Survivor区(8:1:1)
    • 刚创建的对象在Eden区,使用标记-复制算法将存活对象复制到survivor区,之后两个survivor区来回复制存活对象,存活对象达到一定年龄后移动到老年代
  • 老年代基本采用标记-整理算法

新生代和老年代的比例是1:2


G1局部收集算法

  • G1收集器采用了面向局部收集和基于Region的内存布局形式

    每一个Region都可以是任意的Eden,Survivor或者老年代,收集器可以根据不同的Region采用不用的算法进行收集

垃圾收集器

jdk版本使用的默认收集器

  • jdk8默认使用ParallerScavenge和ParallelOld收集器(UseParallelGC)
  • jdk9默认使用G1收集器

新生代收集器

  • Serial(标记-复制算法,单线程)

  • ParNew(标记-复制算法,多线程)

  • Paraller Scavenge(标记-复制算法,多线程,吞吐量优先)

    自适应调节:可以收集系统运行状况,并根据制定的停顿时间或者吞吐量参数动态的调整虚拟机参数

老年代收集器

  • Serial Old(标记-整理算法,单线程)

  • Parallel Old(标记-整理算法,多线程,吞吐量优先)

  • CMS(标记-清除算法,多线程,最短停顿时间)

    缺点:对处理器资源敏感,会产生内存碎片,需要进行内存压缩

    四个步骤:

    • 初始标记:标记GCRoots直接关联到的对象(很快,不需要暂停)
    • 并发标记:标记GCRoots到对象的通路(耗时较长,可并发执行)
    • 重新标记:修正并发标记(短,需要暂停用户线程)
    • 并发清除:回收垃圾(可与用户线程并发执行)

G1收集器

  • 基于Region的内存布局和面向局部收集的收集器
  • 在大内存机器上运行效率高,CMS在小内存机器上运行效率高(平衡点在6~8G内存)
  • jdk9之后默认的垃圾收集器
  • 优先回收价值收益最大的那个Region,且能够实现每次消耗在垃圾回收上的时间大概率不超过指定时间(ms),默认为200ms
  • 收集步骤
    • 初始标记:标记GC Roots直接关联到的对象(很快,不需要暂停)
    • 并发标记:标记GC Roots到对象的通路(耗时较长,可并发执行)
    • 最终标记:并发处理并标记遗留的内容(时间较短,需要暂停用户线程)
    • 筛选回收:选择价值收益最大的进行回收(需要暂停用户线程)

低延时收集器

Shenandoah

  • 基于Region的内存布局
  • 支持并发的整理算法
  • 使用连接矩阵的数据结构记录跨Region的引用关系来提升效率

ZGC

  • 基于Region的内存布局
  • 支持并发整理算法
    • 采用染色指针技术:直接把标记信息写在引用对象的指针上,大大提高性能
  • 性能强大,停顿可以控制在10ms之内
  • 只能在linux64位系统上使用(因为染色指针技术的限制,使用指针64位中的中间没用的4位来存储信息)

选择合适的垃圾收集器

  • 注重延迟且jdk较新:考虑ZGC
  • jdk版本旧,内存小于6G:考虑CMS
  • jdk版本旧,内存很大:考虑G1

内存回收实战

  • 以jdk8默认的Parallel Scavenge+Parallel Old为例
    • 新创建对象在Eden分配

      Eden:Survivor:Survivor=8:1:1

    • 长期存活对象进入老年代

    • 大对象直接进入老年代

  • 规则
    • 当Eden区空间不足,会发起一次Minor GC
    • 当老年代空间不足,会发起一次Full GC
  • 调优
    • 主要就是控制Minor GC和Full GC的频率,例如
      • 设置Eden与Survivor的比例
      • 设置多大的对象直接进入老年代
      • 设置几岁之后进入老年代
    • 没有全部适用的方法,要根据不同的系统情况和程序运行情况进行针对性的调优

JVM性能监控和故障处理

基础命令

  • jps:查看java进程
  • jstat:查看虚拟机的运行状态,如内存情况,垃圾收集情况等
  • jinfo:查看和调整虚拟机参数
  • jmap:生成堆快照
  • jhat:分析堆快照工具,在浏览器中查看
  • jstack:查看指定pid的线程堆栈信息

可视化工具

  • JConsole
    • jdk自带的工具,通过jconsole命令即可打开
    • 可以查看内存,线程,元空间等情况(还可以检查死锁)
  • VisualVM
    • 基于插件,对程序影响小,可以用在生产
    • 可以监视处理器,垃圾收集,堆,方法区和线程的信息
    • 方法级的性能分析,找出被调用最多、运行时间最长的方法
    • 通过BTrace插件可以不中断程序的进行远程调试(精确到方法)

性能调优

方针:

  • 查看GC日志和堆内存分布,找出GC延时的问题所在
  • 降低延时到用户可以接受的范围
    • 尽量使得垃圾在Eden区就被回收,从而控制Full GC的频率
      • 调整Eden和Suivivor的比例
      • 调整新生代的内存空间或堆的最大内存空间
      • 调整大对象进入老年代的阈值
    • 根据程序的关注点,根据jdk和服务器配置选择合适的垃圾收集器

类文件结构

通过Class实现无关性

  • 语言无关性
    • 其他语言也能够编译成Class文件
  • 平台无关性
    • Class文件可以运行在任何符合规范的虚拟机上

Class文件结构(重要)

  • magic:魔数

    • 值为0xCAFEBABY
  • Minor Version:类次版本号

  • Major Version:类主版本号

    • 从45开始,jdk8是52|jdk9是53| jdk11是55
  • constant_pool_count:常量计数器

    • 记录常量的个数
  • constant_pool:常量池

    存放字面量和符号引用

    • 字面量:如字符串,final常量等
    • 符号引用:如包名,类和接口名,字段名称和描述符,方法名称和描述符,方法句柄和方法类型,动态调用点和动态常常量
  • access_flag:访问标志

    • 用于标记类或接口的访问权限
  • this_class:类索引

    • 存储类的全限定名(本质是从常量池查询的)
  • super_class:父类索引

    • 存储父类的全限定名(本质是从常量池查询的)
  • inerfaces:接口索引集合

    • 存储实现了哪些接口(本质是从常量池查询的)
  • fields:字段表集合

    • 存储接口或类中声明的变量(本质是从常量池查询的)
  • methods:方法表集合

    • 存储类中的方法名称,访问权限等信息(本质是从常量池查询的)
  • attributes:属性表集合

    • 存储Code属性(方法对应的代码)等信息

字节码指令

字节码与数据类型

  • i代表int|l代表long|s代表short|b代表byte|c代表char|f代表float|d代表double|a代表reference

字节码指令只需理解即可,更多是查询其说明文档

加载和存储指令

  • 将一个局部变量加载到操作数栈
    • iload、iload_、lload、lload_、fload、fload_、dload、dload_、aload、aload_
  • 将一个数值从操作数栈存储到局部变量表
    • istore、istore_、lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、astore_
  • 将一个常量加载到操作数栈
    • bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_、fconst_、dconst_

运算指令

  • 加法指令
    • iadd、ladd、fadd、dadd*
  • 减法指令
    • isub、lsub、fsub、dsub
  • 乘法指令
    • imul、lmul、fmul、dmul
  • 除法指令
    • idiv、ldiv、fdiv、ddiv
  • 局部变量自增指令:iinc
    • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp*

类型转换指令

  • 宽化类型转换时无需指令
    • 例如int转long,float转double等
  • 窄化类型转换时需要指令
    • 例如i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f

对象创建和访问指令

  • 创建类实例
    • new
  • 创建数组
    • newarray、anewarray、multianewarray
  • 访问普通字段和static字段
    • getfield、putfield、getstatic、putstatic
  • 加载一个数组元素到操作数栈
    • baload、caload、saload、iaload、laload、faload、daload、aaload
  • 将操作数栈的元素存储到数组元素
    • bastore、castore、sastore、iastore、fastore、dastore、aastore
  • 检查类实例类型
    • instanceof、checkcast

操作数栈管理指令

  • 将操作数栈的栈定的一个或两个元素出栈
    • pop、pop2
  • 复制栈顶一个或两个并将复制值重新压入栈顶
    • dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
  • 栈顶的两个数值互换
    • swap

控制转移指令

  • 条件分支
    • ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
  • 符合条件分支
    • tableswitch、lookupswitch
  • 无条件分支
    • goto、goto_w、jsr、jsr_w、ret

方法调用和返回指令

  • 调用对象的实例方法
    • invokevirtual
  • 调用接口方法
    • invokeinterface
  • 调用特殊方法,如初始化方法、私有方法和父类方法
    • invokespecial
  • 调用静态方法
    • invokestatic
  • 运行时动态解析
    • invokedynamic

同步指令

  • 方法级同步
    • 通过ACC_SYNCHRONIZED判断方法是否被声明为同步方法
  • 代码块同步
    • 通过monitorenter和monitorexit两条指令支持同步语义

类加载机制

类加载时机

  • 主动引用
    • 当遇到newgetstaticputstaticinvokestatic指令时,对类进行初始化
      • 使用new关键字实例化对象时
      • 读取或设置一个static字段
      • 调用一个类的静态方法
    • 调用反射时,对类进行初始化
    • 初始化子类时,先触发父类的初始化
    • 当虚拟机启动,执行主类时,会先初始化这个主类
    • 如果接口定义了默认方法,且接口又被自类实现,在自类初始化前,先进行接口的初始化
  • 被动引用
    • 子类引用父类的静态字段,不会导致子类初始化,只会导致父类初始化
    • 通过创建类的数组,不会触发类的初始化
    • 调用类的常量,不会触发类的初始化(因为直接引用常量池中的变量)

类加载过程(重要)

共分为主要的5个步骤:加载、验证、准备、解析、初始化

加载

  • 通过类的全限定名获取类的二进制字节流
  • 将字节流转化为运行时数据结构
  • 在内存中创建该类的Class对象,作为方法区中该类的数据访问入口

验证

  • 文件格式验证
    • 验证Class文件中的结构内容是否合规
      • 是否以魔数0xCAFEBABY开头
      • 主、次版本号的验证
      • 。。。
  • 元数据验证
    • 对字节码进行语义分析,保证其符合java语言规范
      • 该类是否有父类,几个父类
      • 该类是否继承了被final修饰的父类
      • 是否实现了该实现的方法(如果继承抽象类)
      • 。。。
  • 字节码验证
    • 最复杂的阶段,主要是通过数据流分析和控制流分析确定程序语义合法
  • 符号引用验证

准备

为类中的变量分配内存并设置初始值

  • 此时内存分配的只是类的静态变量,不包括实例变量,实例变量将会在对象实例化时随对象分配到java堆中
  • 这里说的初始化时设置为零值

解析

将常量池内的符号引用替换为直接引用的过程(即将类名进行解析并替换为真实类的引用)

  • 类或接口的解析
    • 如果不是数组,则让类加载器加载该类,经过一系列验证通过后,则解析成功
    • 如果是数组类型,先加载数据元素类型,如果需要,再按加载类的规则进行加载
  • 字段解析
    • 解析一个未被使用过的字段符号引用,先将字段的类符号引用进行解析,如果正常,则解析成功
    • 否则,如果字段的类型实现了接口,则按照继承关系加载接口和父接口,再加载该字段类型
  • 方法解析
    • 和字段解析类似,先根据方法所属的类或接口的符号引用进行解析,如果正常,则返回方法的直接引用
    • 否则,如果该类有父类或接口,则递归的查找,然后返回方法的直接引用
  • 接口方法解析
    • 先解析方法所属的类或接口的符号引用,如果正常,则返回方法的直接引用
    • 否则,如果接口有父接口,则递归的查找,然后返回方法的直接引用

初始化

执行类构造器clinit方法(由编译器生成)

  • 将所有类变量的赋值和静态语句块都合并到该方法中执行
  • 如果该类存在父类,则先执行父类的clinit方法
  • 虚拟机保证了一个类的clinit方法是线程安全的

类加载器

名称空间

类+类加载器构成唯一标志,即类加载器是类的类型的名称空间

比较两个类是否相等:类加载器相同+类的Class文件相同

双亲委派模型(重要)

默认类加载器

  • 启动类加载器(BootStrap ClassLoader):加载java_home\lib目录下的类库
  • 扩展类加载器(ExtClassLoader):加载javahome/lib/ext目录下的类库
  • 应用程序类加载器(AppClassLoader):加载用户类路径下的类库

通过组合方法构建父子关系

  • 在Launcher类中,有两个内部类,一个是ExtClassLoader,一个是AppClassLoader
  • 在Launcher的构造函数中,先创建ExtClassLoader(通过其构造函数传入其父类启动类加载器,引用为null)
  • 在创建AppClassLoader(通过其构造函数传入其父类扩展类加载器的实例引用)
  • 之后通过parent.loadClass方法让其父类先尝试加载对象的类,如果加载不成功,在调用自身的loadClass进行加载

破坏双亲委派模型

  • 自定义类加载器,并重写loadClss方法
  • 使用线程上下文类加载器

类加载案例

  • tomcat自定义类加载器实现隔离
    • tomcat内部实现了很多自定义类加载器,用于隔离不同应用下使用的类
  • 字节码生成技术与动态代理
    • 通过对类的字节码增强,再通过类加载器将字节码解析并加载到jvm中,从而实现动态代理
  • 实现远程动态执行代码
    • 将本地编写的java代码编译后,将字节码传输到服务器
    • 服务器接收到字节码后,通过自定义的类加载器进行加载,然后通过反射调用类的main方法执行即可
    • 执行完成后,将执行结果返回给客户端,然后再将类进行卸载

字节码执行引擎

运行时栈帧结构(重要)

局部变量表

  • 一维数组,每个值对应的都是一个变量槽,一个变量槽可以放32位的变量(例如int,short,float等)。对于64位的变量,需要存放连续两个变量槽(例如long和double)
  • 方法调用时的参数传递通过局部变量表完成,本质上传递的就是变量在局部变量表中所存储的位置,即索引
    • 第0位一般存放的是该实例对象的this指针,这样就可以访问到该对象的成员变量
    • 其余索引是按照参数顺序派别的,从第一个变量槽开始
  • 为了节省空间,局部变量表的变量槽是可以重用的

操作数栈

  • 是一个后进先出的堆栈,保存字节码指令需要操作的数据(32位变量占用一个栈容量,64位变量占用两个栈容量)
  • 方法执行时,会执行对应的字节码指令,就会对应数据从操作数栈入栈或出栈
  • 例如iadd指令,将操作数栈栈顶的两个元素出栈进行相加,然后将结果在入栈

动态链接

执行运行时常量池中该栈帧所属方法的引用,支持这个引用是为了支持方法调用过程中的动态链接

方法返回地址

  • 保存方法的返回地址
    • 如果正常退出方法,则通过方法地址返回
    • 如果发生异常
      • 获取并处理了异常,继续正常返回方法
      • 未捕获异常,返回地址通过异常处理表来确定
  • 方法退出的过程就是栈帧出栈的过程
    • 恢复上个方法的局部变量表和操作数栈
    • 把返回值压入到调用者栈帧的操作数栈中
    • 将pc计数器指向方法调用指令后面一条指令

方法调用

主要目的:确定调用哪一个方法(多态:直到运行时才确定目标方法的直接引用)

主要分为两种调用:解析调用和分派调用

解析调用

在编译器编译的时候就会确定调用哪一个方法

例如调用静态方法、私有方法、构造器、父类方法

分派调用(重要)

静态分派

  • 多个方法重载时,根据变量的静态类型来确定调用哪个方法(通过使用invokevirtual指令来调用方法)
    • 如果两个子类实例传入重载方法中,静态分派会根据传入变量的静态类型(即父类类型)来确定调用具体哪个重载方法方法,而不会关心传入的具体时哪个实例类型
    • invokevirtual指令会关注调用方法的实际对象是哪个,然后在调用真实对象的方法引用,但是在本例当中,并不关心方法具体实际的调用对象是哪个,而是关心在多个重载方法中根据静态类型选择对应的方法

动态分派(重要,多态的实现:通过invokevirtual指令)

  • 即多态,父类引用指向子类对象,然后调用对应重写的方法,那么在确定到底调用哪个方法的时候就会进行动态分派
  • 在编译时会生成invokevirtual指令进行调用父类的方法,invokevirtual指令在运行时解析过程如下:
    • 找到操作数栈顶元素所指向对象的实际类型,记作C
    • 如果在类型C中找到了与常量中的描述符和简单名称都相同的方法,在进行权限验证,如果通过了,则返回方法的直接引用,查找过程结束
    • 否则,按照继承关系从下往上对C的各个父类进行第二步的搜索和验证,一旦找到,则返回方法的直接引用
    • 如果都没找到,则抛出异常

单分派和多分派

  • java是一门静态多分派和动态单分派的语言
    • 静态多分派:允许方法重载多个,在编辑期根据参数类型的静态类型就确定调用的方法
    • 动态单分派:指重写方法后,父类引用指向子类对象,只有在运行时才能确定具体是哪个真实对象调用的方法,通过继承关系从下往上搜索来动态的确定调用哪个方法
  • 虚拟机动态分派的实现
    • 在方法结构表中存放着各个方法的实际入口地址,如果方法在子类中未重写,则子类的方法地址指向和父类相同的地址,如果子类重写了方法,则子类的方法地址指向子类重写后的方法地址

字节码执行引擎(重要)

字节码执行方式

  • 解释执行(通过解释器执行)
  • 编译执行(通过即时编译器产生本地代码执行)

基于栈的解释器执行过程

  • 例如执行1+1的操作如下
    • iconst_1:将常量1压入操作数栈
    • iconst_1:将常量1压入操作数栈
    • iadd:把栈定的两个元素出栈,相加的结果在压入操作数栈
    • istore_0:将栈定的元素保存到局部变量表的第0个变量槽中
  • 通过程序计数器来控制上述字节码的指令执行,即保存下一条字节码的指令

程序编译与代码优化

前端编译

  • 一般来说,前端编译的优化有限,也意义不大
  • 但是可以针对java语言的编码过程来将降低编码的复杂度,提高编码效率,例如语法糖

后端编译

  • 后端编译中的即时编译器能够在运行器进行代码优化,能够使得程序的执行效率提升

前端编译与优化

javac的编译过程的4个阶段

  • 准备过程
    • 初始化插入式注解处理器
  • 解析与填充符号表过程
    • 词法、语法分析,将代码转化为标记集合,构造出语法树
    • 根据语法树来填充符号表(用于之后的查询)
  • 插入式注解处理器的注解处理过程
  • 分析与字节码生成过程
    • 标志检查:对语法的静态信息进行检查
    • 数据流与控制流分析:对程序动态运行过程进行检查
    • 解语法糖:将语法糖还原为原始代码
    • 字节码生成:将代码转化成字节码

在代码的编译阶段,编译器会为我们做很多事情,例如自动生成clinit函数用来初始化变量和static代码块,解语法糖(包括泛型的擦除,自动装箱和自动拆箱等)

java语法糖

  • 泛型:java采用了擦除式泛型,即在代码编译后,泛型就会通过强转的方式被擦除
  • 自动装箱、拆箱
    • 自动装箱:Integer i=1;

      在代码编译后:Integer i=Integer.valueOf(1);

    • 自动拆箱:Interger i=new Integer(1);int j=i;

      在代码编译后:int j=i.intValue();

  • Foreach遍历:将代码还原成迭代器实现
  • 变长参数:将变长参数创建为一个数组传入

后端编译与优化

即时编译器

虚拟机在刚启动时使用解释执行,当发现某个方法或代码块调用频繁(热点代码),为了提高热点代码的执行效率,将热点代码即时编译为本地机器码执行

  • 为什么不在前端编译时就将代码优化为本地机器码执行?
    • 因为其他语言也可以通过编译器生成Class文件在虚拟机上运行,但是就享受不到即时编译给代码优化带来的红利
    • 前端编译不能在程序运行时收集有用的信息,因此代码优化质量不一定比即时编译高
  • 解释器和即时编译器两者优势
    • 当程序刚启动时,解释器先发挥作用,省去编译时间,可以立即运行
    • 随着时间的推移,即时编译逐渐发挥作用,把越来越多的代码编译成本地代码,提高执行效率

提前编译器

在程序运行之前,就对代码进行编译优化

  • 优点
    • 不会占用程序运行时间和运算资源
    • 给即时编译器做缓存加速,加快java程序达到最大性能

编译器优化技术

方法内联

  • 优点
    • 去除方法调用的成本,例如寻找方法、建立栈帧等
    • 是其他优化技术的基础
  • 将方法代码直接内联到其他方法中,再进行进一步的优化
    • 冗余访问消除
    • 复写传播
    • 无用代码消除等

逃逸分析

  • 如果对象在方法中被定义,但作为调用参数传递到其他方法,则称为方法逃逸;如果被外部线程访问到,则称为线程逃逸
  • 根据逃逸分析就可以对代码进行不同程度上的优化
    • 栈上分配
      • 如果对象通过逃逸分析后确保不会逃逸出线程之外,则可以让对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁,可以降低垃圾回收的压力
    • 标量替换
      • 如果逃逸分析能够证明一个对象不被方法外部访问到,则对象可以拆分成多个标量来代替,读取速度更快,且避免了对象的创建
    • 同步消除
      • 如果逃逸分析能确保一个标量不会逃逸出线程,则该变量不会存在竞争,则可以将对变量的同步消除

Java内存模型

硬件效率与一致性

  • 为什么采用多线程
    • 因为CPU和内存速度差距太大,采用多线程可以尽可能的利用CPU资源
  • 进一步优化,将CPU和内存之间加入一层或多层高度缓冲,提高性能
  • 加入缓冲后,又存在缓存一致性问题
    • 在多核心CPU中,每个核心都有自己的高速缓存,但是多核心又共享同一个主内存,每个核心都会将自身高速缓存的内容同步到主内存,从而导致缓存一致性问题

Java内存模型

分为两类内存

  • 主内存

    所有对象都存储在主内存

  • 工作内存

    每个线程都有自己的工作内存,线程对变量的操作都必须在自己的工作内存中进行,而该变量就是在主内存中的副本,该副本会在适当的时机将值同步会主内存

    不同线程之间不能直接访问对方的工作内存,都需要经过主内存来传递

内存交互规则(重要)

Java内存模型规定了一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存

  • 8种操作
    • lock:锁定:线程独占一个变量
    • unlock:解锁:线程释放锁定的变量
    • use:使用:将工作内存中的一个变量的值传递给执行引擎
    • assgin:赋值:将执行引擎接受到的值赋给工作内存的变量
    • read:读取:将一个变量从主内存传输到线程的工作内存,以便load使用
    • load:载入:将传输到工作内存的变量加载到变量副本中
    • store:存储:将工作内存中的一个变量值传输到主内存,以便write使用
    • write:写入:将store到主内存中的值赋值给主内存中的变量
  • 简化(重要)
    • 在使用sychronized关键字时,自动执行lock和unlock操作
    • 把一个变量从主内存读到工作内存,需要按顺序执行read和load操作
    • 把一个变量从工作内存同步回主内存,需要按顺序执行store和write操作
    • 注意:Java内存模型值要求上述两个操作必须按顺序执行,但是两个操作可能存在交替顺序执行。例如:read a、read b、load b、load a

volatile规则

可见性

  • 当一个变量被声明为volatile之后,该变量对所有线程具有可见性,即当一个线程修改了变量值,新值对于其他线程来说是立即得知的,而普通变量需要通过主内存传递才能完成(并且中间还会夹杂其他操作,导致数据错误)
  • 被volatile声明的变量在每次使用时都需要从主内存获取新值,在每次修改时都要立刻同步回主内存,所以,从宏观上看来,对于每个线程来说,具有可见性

禁止指令重排序

通过生成一个内存屏障,来保证在内存屏障后面的操作不会乱序

示例:双重检查锁

  • 创建对象的时候分为了三个步骤,因此可能会存在指令重排序的情况,导致发生未初始化的单例对象就直接被使用
  • 未指令重排序的步骤
    • 分配对象内存
    • 对象初始化
    • 将对象的引用指向变量
  • 指令重排序后
    • 先分配对象内存
    • 将对象的引用指向变量
      • 在这一步的时候,代码的第一次判断对象的引用不为null,就直接将对象返回,导致未初始化,对象就被使用
    • 对象初始化

Java内存模型对volatile定义的规则总结:

  • 可见性的规则
    • 线程在对volatile变量执行use指令前,必须先执行load指令
      • 保证了在工作内存中,如果需要使用volatile变量前,都必须先从主内存刷新最新的值,以保证可以看到其他线程对volatile变量的操作
    • 线程在对volatile变量执行assign指令后,必须马上执行store指令
      • 保证了在工作内存中,每次volatile变量在西欧该之后,必须立刻同步回主内存,以保证其他线程可以看到自己对volatile变量做的修改
  • 禁止指令重排序的规则
    • 假定线程T对变量V实施了use或assign的A动作,F动作是与A动作对应的load或store动作,P动作是与F动作对应的read或write动作
    • 假定线程T对变量W实施了use或assign的B动作,G动作是与B动作对应的load或store动作,Q动作是与G动作对应的read或write动作
    • 则如果A先于B,那么P先于Q

三要素

  • 原子性:一个操作不可再分

    保证原子性的四种方式:

    • Java内存模型来直接保证了read、load、assign、use、store和write这6个指令是原子性操作
      • 因此大致可以认为,基本数据类型的访问、读写都是具备原子性的(long和double例外)
    • java内存模型还提供了lock指令和unlock指令来保证原子性
      • 例如同步方法
    • java内存模型还提供了monitorenter和monitorexit指令保证原子性
      • 例如同步代码块
    • 通过硬件层面上实现的CAS来保证原子性
  • 可见性:当一个线程修改了共享变量的值时,其他线程能够立即的值这个修改

    保证可见性的三种方式:

    • 声明volatile
    • 通过sychronzied修饰
    • 通过final修饰
  • 有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的

    即前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象

    保证有序性的两种方式:

    • 声明volatile来禁止重排序
    • 通过声明sychronzied保证同一个时刻只允许一个线程对变量进行lock操作

先行发生原则(重要)

通过先行发生原则可以判断数据是否存在竞争,线程是否安全(是内存交互规则的简化版)

规则如下:

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。

    注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

  • 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。这里必须强调的是“同一个锁”。

    例如使用sychronized关键字后,即可符合这条规则,就有顺序性保障。

  • volatule变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作

    例如使用volatile关键字声明一个变量后,即可符合这条规则,就有顺序性保障。

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

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

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

  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

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


依据先行发生原则对下面这段代码分析,看是否存在线程安全问题:

private int value=0;

public void setValue(int value){
    this.value=value;
}

public int getValue(){
    return value;
}

假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则:

  • 由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;
  • 由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;
  • 由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;
  • 后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。
  • 因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起。

因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。

那怎么修复这个问题呢? 至少有两种比较简单的方案可以选择:

  • 要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;
  • 要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来 实现先行发生关系。

线程

线程实现

  • 内核线程实现(Java采用)
    • 一对一线程模型:每个轻量级进程都有一个内核线程支持
    • 直接由操作系统内核支持的线程,由内核来完成线程的切换
    • 程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口:轻量级进程,即我们通常意义上的线程
    • 轻量级进程的局限性
      • 基于内核线程实现,线程系统调用代价较高,需要在用户态和内核态来回切换
      • 每个轻量级进程都需要有一个内核线程的支持,会消耗一定的内核资源,因此一个系统支持的轻量级进程的数量是有限的
  • 用户线程实现
    • 一对多线程模型:用户线程的各种操作都在用户态完成,不需要切换到内核态,因此可以快速且低消耗,也可以支持规模更大的线程数量
    • 用户线程的优缺点
      • 优点:不需要内核支持,高效
      • 缺点:实现复杂且困难,线程所有的操作都需要用户来实现
  • 混合实现
    • 将内核线程和用户线程结合

Java线程调度

  • 协同式线程调度:线程的执行时间由线程自身控制,自己执行万后,就主动通知系统切换到另一个换成

    优缺点:

    • 优点:实现简单
    • 缺点:线程执行时间不可控,如果代码编写有问题,程序可能会一直阻塞在那里
  • 抢占式线程调度(Java采用):线程由操作系统来分配执行时间,线程的切换由操作系统决定

    优缺点

    • 优点
      • 实现简单,直接交给操作系统控制
      • 会尽可能的避免系统一直阻塞的问题
    • 缺点:线程的调度是不可控的,完全由操作系统决定

状态转化

线程共有5种状态:

  • 新建(New):创建线程后未启动
  • 运行(Runnable):包括了Running和Ready(即线程可能正在运行,或者等待CPU分配时间片)
  • 等待
    • 无限期等待(Waitting):需要等待其他线程显式唤醒,在未唤醒时系统不会为其分配时间片

      以下方法会让线程进入无限期等待

      • wait方法
      • join方法
    • 限期等待(Time Waitting):在一定时间后由系统自动唤醒,在未唤醒时系统不会为其分配时间片

      以下方法会让线程进入限期等待

      • wait(time)方法
      • join(time)方法
      • sleep(time)方法
  • 阻塞
    • 线程处于阻塞状态,在等待着获取到一个排他锁
    • 在程序等待进入同步区域的时候,线程将进入这个状态
  • 结束
    • 线程已经结束执行

协程

由一个线程实现所有的线程操作,减少用户态和内核态之间的转化

优缺点

  • 优点
    • 减少用户态和内核态之间来回转化的成本
    • 减少了线程栈的空间占用
  • 缺点
    • 实现复杂

线程安全与锁优化

线程安全两种方式

sychronized关键字

  • 同步代码块
    • 通过javac编译后,会在同步块前后生成monitorenter和monitorexit指令,这两个指令都存在一个参数指向锁定和解锁的对象
    • 在执行monitorenter指令时,首先要尝试获取对象的锁,如果对象没被锁定或者已经持有了该对象,则把锁的计数器的值加1,而在执行monitorexit时,会将锁计数器的值减1
    • 如果锁计数器的值为0,则锁被释放了
    • 如果获取锁失败,则当前线程会进入阻塞状态,直到锁对象被释放,然后又重新参与锁的竞争,如此往复,直到竞争到锁
  • 方法同步
    • 如果是普通同步方法,则存在一个参数指向对象实例作为线程要持有的锁
      • 在方法表中会标示该方法是一个普通同步方法
    • 如果是静态同步方法,则存在一个参数指向Class对象作为线程要持有的锁
      • 在方法表中会标志该方法是一个静态同步方法

CAS+Volatile关键字

Lock类实现如:ReentrantLock

  • 底层代码通过AQS类来实现
  • 增加了一些高级功能
    • 等待可中断
      • 当前线程长时间等待锁的释放时,该线程可以选择放弃等待,改为处理其他事情
    • 公平锁
      • 多个线程等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁
    • 锁绑定多个条件
      • 一个ReentrantLock对象可以同时绑定多个Condition对象。

CAS的硬件支持

  • 通过硬件层面的支持,保证了需要多次操作的行为可以只通过一条指令就能完成,从而保证了原子性
  • CAS需要三个操作数,分别是内存位置V、旧值A、新值B
  • 当CAS指令执行时,当且仅当V等于A时,才会将V值更新为B值

在java中,通过使用Unsafe类来完成CAS的操作调用

ABA问题:通过控制版本来保证CAS的正确性

锁优化

  • 自旋锁
    • 因为互斥同步中,线程的挂起和恢复需要用户态和内核态的来回转换,代价较高,但是,在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这个短时间去挂起和恢复线程不值得,因此,出现了自旋锁,让线程在未竞争到锁时,不放弃时间片,自旋一会儿,等待锁的释放,而不是将自己挂起
    • 优缺点
      • 避免了线程的切换开销,但是会占用处理器的时间
      • 当锁被占用时间很短的时候,自旋等待的效果很好;反之,则会浪费cpu资源
    • 默认的自旋次数是10次,如果超过自旋次数还未获得锁,则使用传统方式去挂起线程
  • 锁消除
    • 在即时编译器运行时,如果通过逃逸分析检测到共享数据不可能存在竞争,则会对原本加锁的代码进行锁消除
  • 锁粗化
    • 原则上,推荐将同步块作用范围尽量小:只在共享数据的实际作用域中才进行同步
    • 但是如果出现在同一个对象上反复进行加锁和解锁,甚至加锁操作出现在循环体中,这时就会将锁粗化,使其加锁的范围扩大,避免反复加锁和解锁

锁升级

  • 无锁:在对象头的Mark Word中,标志位标记为无锁状态
  • 偏向锁
    • 特点
      • 目的:消除数据在无竞争下的同步
      • 和轻量级锁的区别:轻量级锁在无竞争下使用CAS来消除同步的互斥量,而偏向锁是在无竞争下吧整个同步都消除(包括CAS操作)
      • 偏向锁的偏是偏心的意思,该锁会偏向于第一个获得它的线程,如果该锁一直没有被其他线程获取,则持有偏向锁的线程将不需要进行同步就可以执行同步块中的代码
    • 升级过程
      • 当锁对象第一次被线程获取时,虚拟机会将对象头中的锁标志位设置为01,偏向标志位设置为1,表示进入偏向模式
      • 然后再使用CAS操作将当前线程ID记录在对象的Mark Word中
        • 如果CAS成功,则说明线程持有该偏向锁,且线程以后再次进入该锁相关的同步块时不需要再进行任何同步操作
        • 如果CAS失败,说明至少存在一个线程在竞争锁,然后虚拟机会将锁膨胀为轻量级锁,将锁标志为更新为00
  • 轻量级锁
    • 概述:轻量级锁不是代替重量级锁,而是在竞争不激烈的情况下,采用CAS的方式获取锁,来避免使用操作系统互斥量产生的性能消耗
    • 升级过程
      • 在代码即将进入同步块时,需要先持有锁对象:在当前线程的栈帧创建一个名为锁记录的空间,用于存储锁对象当前的Mark Word拷贝
      • 再通过CAS操作尝试将锁对象的MarkWord更新为指向在当前线程的名为锁记录的栈帧
        • 如果更新成功,表示该线程持有了该锁
          • 进入同步代码块执行代码
        • 如果更新失败,再次通过CAS尝试获得该锁,如果失败超过了一定次数,则将锁膨胀为重量级锁,将锁对象的标志为修改为10
  • 重量级锁
    • 在升级为重量级锁之后,线程在竞争锁对象时,如果竞争成功,则直接进入同步块执行代码;如果竞争失败,则线程会被挂起,进入阻塞状态,直到该锁被释放后,虚拟机会唤醒该锁对应的阻塞状态的线程重新竞争锁
0条评论
头像
ICP证 : 浙ICP备18021271号