类文件结构
根据Java虚拟机规范,class文件格式用一种类似于c语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。无符号数是基本的数据类型,这里以u1, u2, u4, u8来分别代表1、2、4、8个字节。无符号数可以用来描述数字、索引引用、数量值或按UTF-8编码构成的字符串值。表是由多个符号数或其他表作为数据项构成的复合数据类型,所有表都以_info结尾。
class文件格式:
类型 | 名称 | 数量 | 描述 |
u4 | magic | 1 | 魔数的唯一作用仅用来确认一个文件是否为一个能被虚拟机接受的class文件,因为扩展名可以随意改动 |
u2 | minor_version | 1 | 次版本号 |
u2 | major_version | 1 | 主版本号 |
u2 | constant_pool_count | 1 | 常量池个数 |
cp_info | constant_pool | constant_pool_count-1 | 常量池主要存放字面量(字符串、final常量)和符号引用(类和接口的全限定名,字段和方法名的名称和描述符)。常量池中每一项都是一个表,常量池有十几种不同的类型。其中一个类型为constant_utf8_info。 由于class文件中的方法、字段都需要引用constant_utf8_info型常量来描述名称,所以constant_utf8_info型常量的最大长度页就是java方法、字段名的最大长度。constant_utf8_info的长度用2个字节表示,所以变量/方法名的长度不能超过65535(64KB),否则会编译失败。可以使用javap分析字节码:javap -verbose |
u2 | access_flags | 1 | 类的访问标志。是否public,是否final,是否是注解/枚举/接口 |
u2 | this_class | 1 | 指向类型为constant_class_info的常量 |
u2 | super_class | 1 | 指向类型为constant_class_info的常量 |
u2 | interfaces_count | 1 | |
u2 | interfaces | interfaces_count | |
u2 | fields_count | 1 | |
field_info | fields | fields_count | |
u2 | methods_count | 1 | |
method_info | methods | 1 | 方法里的代码经过编译器编译成字节码指令后,存放在方法属性表集合中名为Code的属性。Java代码的方法签名不包括返回值,但字节码的方法签名包括返回值 |
u2 | attributes_count | 1 | |
attribute_info | attributes | attributes_count |
虚拟机类加载机制
类加载器系统负责加载class文件,并将字节码保存在内存中的方法区。
- 在JVM虚拟机中有个类加载器叫启动类加载器,使用C++实现,负责加载/lib目录下的类
- 在JDK中有个扩展类加载器,使用java实现,负责加载/lib/ext目录下的类。
- 应用程序类加载器/系统类加载器负责加载用户类路径上的所有类
JDK9之后为了实现模块化
- 扩展类加载器被平台类加载器取代
- 平台类加载器和应用程序类加载器不再继承URLClassLoader,而是继承了BuiltinClassLoader
类加载器加载类的三个关键字:委托机制、可见性、唯一性
- 委托机制:当虚拟机需要一个类时,首先查看这个class是否已经加载。如果没有加载,就是使用Application ClassLoader类加载器进行加载;Application ClassLoader又会委托Extension ClassLoader进行加载;Extension ClassLoader又会委托Bootstrap ClassLoader进行加载。
- 可见性:类加载器可以看见父加载器加载的类,反过来却不行
- 唯一性:如果一个类被父类加载器加载了,那么它的子加载器则不会加载。
类加载的触发条件
- 遇到new, getstatic, putstatic, invokestatic这4条字节码指令时
- 使用java.lang.reflect包的方法对类进行反射调用的时候
- 当初始化一个类时需要先初始化父类
- 虚拟机启动时main方法所在的主类
- 如果java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic, REF_putStatic, REF_invokeStatic的方法句柄所对应的类没有进行过初始化。
- 不会触发类加载的示例:
- 通过子类引用父类的静态字段,不会导致子类初始化
- 通过数组定义来引用类,不会触发此类的初始化
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。
类加载的生命周期
类加载器从加载类到虚拟机内存到卸载,整个生命周期包括:加载、连接(验证、准备、解析)、初始化、使用、卸载。
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将二进制字节流转化为方法区的运行时数据结构
- 在内存(在方法区)中生成一个代表类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
验证
文件格式验证
验证是否符合类文件结构
元数据验证
验证父类是否存在、是否继承了不允许继承的类(final class)
字节码验证
验证方法体的语法是否正确
符号引用验证
符号引用验证发生在虚拟机将符号引用转化为直接引用的时候,这个动作在解析阶段发生。
准备
虚拟机为类变量分配内存并设置类变量初始值(零值)
解析
虚拟机将常量池内的符号引用替换为直接引用。需要解析的符号引用有:类或接口、字段、类方法、接口方法
符号引用:以一组符号描述所引用的目标,可以是任何形式的字面量
直接引用:直接指向目标的指针、相对偏移量、能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标一定已经在内存中了。
初始化
执行类中定义的字节码。
执行类构造器()方法。()方法由编译器自动收集类的类变量的赋值动作和静态语句快中的语句合并产生的。虚拟机会保证()方法在多线程环境中被正确的加锁、同步,()只会被执行一次。
方法调用
方法的调用分为两种:解析和分派。在JVM中提供了5条方法调用字节码指令:
- invokestatic:调用静态方法
- invaokespecial:调用实例构造器方法、私有方法和父类方法
- invokevirtual:调用所有虚方法。(虚方法就是指除了静态方法、私有方法、父类方法、构造函数和final方法的方法)
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,前面的4条调用指令的分派逻辑是固化在JVM内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
解析
在类加载的解析阶段,会将其中一部分方法的符号引用转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法的调用称为解析。解析调用是一个静态的过程,在编译期就可以完全确定,在类装载的解析阶段就会把设计的符号引用全部转变为直接应用,不会延迟到运行期完成。
在Java中符合“编译器可知,运行期不可变”条件的方法主要包括静态方法和私有方法。静态方法与类型直接关联,私有方法不能被外部访问,所以这两种方法都不能被重写。
分派
分派调用可能是静态也可能是动态的,分派可以分为单分派和多分派。所以分派分为:静态单分派、静态多分派、动态单分派、动态多分派。
依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译阶段。
基本类型的自动转型:char->int->long->float->double
依赖动态类型来定位方法执行版本的分派动作称为动态分派,动态分派的典型应用是方法重写。动态分派发生在运行阶段。动态分派编译后的指令时invokevirtual。它会根据对象的真正类型从子类到父父类依次寻找对应的方法。
分派调用的优化手段:虚/接口方法表、内联缓存、基于“类型继承关系分析”技术的守护内联。
字节码指令
Java虚拟机的指令由一个字节长度的数字(操作码)以及随后跟着的多个参数(操作数)构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数指令的都不包含操作数,只有一个操作码。
由于class文件格式放弃了编译后代码的操作数长度对齐,所以虚拟机在处理超过1个字节数据时,必须在运行时从字节中重建具体数据的结构。如果要将一个16位长度的无符号整数使用两个字节存储,那么它的值应该是(byte1<<0)|byte2。这种操作在导致解释执行字节码时损失一些性能。但这样做的优势是放弃了操作数长度对齐,可以省略很多填充和间隔符号。
指令的分类:
- 加载指令。将局部变量加载到操作栈。eg: iload、iload_、fload、fload_
- 存储指令。将数值从操作数栈存储到局部变量表。eg: istore、fstore
- 运算指令。用于对两个操作数栈上的值进行某种运算,并把结果重新存入操作栈顶。eg: iadd、ladd、isub、lsub
- 类型转换指令。eg: i2b、i2c
- 对象创建指令。eg: new、newarray
- 对象访问指令。eg getfield、baload、bastore
- 操作数栈管理指令。用户操作操作数栈。eg: pop、pop2、swap
- 控制转移指令。eg: ifeq
- 方法调用和返回指令。
- 异常处理指令。
- 同步指令。eg: monitoreter、monitorexit
Java运行时的内存区域
程序计数器
一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,每个线程都有独立的程序计数器
Java虚拟机栈
每个线程都有独立的栈,是Java方法执行的内存区域,每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。当调用一个方法时,就会有一个栈帧入栈,当方法调用完成之后,对应的栈帧就出栈。
局部变量表存放了编译期就已经知道的基本数据类型和复杂对象的引用。其中64位长度的long和double会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表需要的内存大小在编译完成后就已经确定,在方法运行期间不会改变局部变量表的大小。
虚拟机栈的两种异常状况:StackOverflowError、OutOfMemoryError
局部变量表
存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Varialbe Slot)为最小单位,每个变量槽都可以存放boolean、byte、char、short、int、float、reference、returnAddress类型。
变量槽可以复用。如果某个变量不会再被访问到,后面又有新的变量赋值则可能可以复用变量槽。
操作数栈
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。字节码中的符号引用在类加载阶段或第一次使用就转化为直接引用,这种转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
本地方法栈
执行Native方法时的栈
Java堆
Java堆是线程共享的内存区域,在虚拟机启动时创建。用于存放对象实例。
从内存回收的角度看,Java堆分为新生代和老年代。新生代都分为Eden空间、From Survivor空间、To Survivor空间。
从内存分配的角度看,Java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allcation Buffer,TLAB)。
方法区
方法区是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。
HotSpot虚拟机在Java7之前使用的永久代(Permanent Generation)实现方法区;在Java7中使用Java堆管理常量和静态变量,其它不变;从Java8开始使用元空间实现方法区的其它部分类信息、即时编译器编译后的代码。
直接内存
使用Native函数直接分配堆外内存,然后通过Java堆中的DirecByteBuffer对象引用堆外内存,这样避免了在Java堆和Native堆中来回复制数据,可以提高性能。例如:NIO的缓存区就可以申请直接内存。
HotSpot虚拟机对象
对象的内存结构
对象在内存中分为3个区域:对象头、实例数据、对齐填充
对象头包括Mark Word和类型指针。Mark Word用于存储对象自身的运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向ID、偏向时间戳。
对象的创建过程
- 虚拟机遇到new操作指令时,
- 首先检查方法区,是否能在常量池中定位到类的符号引用,并检查类是否已经加载完成;
- 然后虚拟机为对象分配堆内存,一个对象需要多大的内存在类加载完成后就可以确定下来;
- 然后虚拟机分配到的内存空间初始化为0(不包括对象头)
- 然后虚拟机初始化对象的对象头。对象头只要保存对象锁属的类、对象的哈希吗、对象的GC分代年龄、对象的锁信息。
- 然后执行方法,初始化对象。
堆内存为对象分配内存的方式
- 指针碰撞(Bump the Pointer):假设Java堆中内存是绝对规整的,一边的内存是正在使用的,一边是空闲的,那分配内存就是把中间的指针移动一段距离,这种方式成为“指针碰撞”。
- 空间列表(Free List):如果堆内存不是绝对规整的,虚拟机维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找个一个合适的空间分配给对象,并更新列表记录,这种分配方式称为“空闲列表”
堆内存是否规整是由虚拟机采用的垃圾收集器是否带有压缩整理功能决定的。因此在使用Serial、PraNew等带Compact过程的收集器时,采用的指针碰撞方式,而使用CMS这种基于标记、清除算法的收集器时,采用的是空闲列表方式。
对象创建在虚拟机中是非常频繁的行为,这个操作不是线程安全的,可能出现正在给对象A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题的方式有两种:
- 堆分配内存空间的动作进行同步处理,采用CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间中进行,每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
垃圾收集器
判断对象存活的算法
- 引用计数算法。无法解决循环引用问题
- 可达性分析算法。通过一系列称为GC Roots的对象作为起始点向下搜索,当一个对象到达GC Roots没有任何引用链时,则对象不可用。Java中可最为GC Roots的对象包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法引用的对象
引用的分类
- 强引用(FinalReference)。只要存在,垃圾收集器永远不会回收
- 软引用(SoftReference)。描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把软引用对象列进回收范围进行第二次回收。
- 弱引用(WeakReference)。被弱引用关联的对象只能生存到下次垃圾收集发生之前。
- 虚引用(PhantomReference)。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例。虚引用的唯一作用是能够在这个对象被收集器回收时收到一个系统通知。
对强引用对象,在可达性分析中要经历至少两次标记过程。如果在第一次可达性分析中对象到GC Roots不可达,如果对象的finalize被方法没重写且没有被虚拟机调用过,那么会将对象放入F-Queue队列中等待执行finalize方法。如果第二次可达性分析对象仍然不可达,那么对象将会回收,否则对象不会被回收。
垃圾收集算法
- 标记-清除算法。缺点:1. 标记和清除两个过程的效率都不高;2. 标记清除后会产生大量不连续的内存碎片。空间碎片太多导致需要分配大的对象时,无法找到足后的内存而不得不触发一次垃圾回收。
- 复制算法。将内存划分为大小相等的两块,每次只使用其中一块,当一块内存用完了,就将海存活的对象复制到另一块。新生代的对象朝生夕死,需要复制的对象较少,所以适合这种算法。缺点是会浪费一定的内存空间。
- 标记-整理算法。标记后让所有存活的对象后向一端移动,然后直接清理掉端边界以外的内存。
HotSpot垃圾收集算法细节
根结点枚举
方法区所有的常量很多,遍历整个方法区太慢,HotSpot使用OopMap数据结构来维护根节点,HotSpot为字节码指令(不是所有的指令)生成OopMap。
安全点
HoySpot不会为每条指令生成OopMap(否则会需要大量存储空间),只有在安全点生成OopMap,同时也意味着只有虚拟机所有线程都到达安全点,才能开始垃圾收集。
安全点不能太少以至于让收集器等待太长时间,页不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都很短,程序不太可能因为指令流太长而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,所以只有具有这些功能的指令才会产生安全点
让程序在安全点停顿下来有两种方案:
- 抢先式中断(Preemptive Suspension)。在垃圾收集器发生时,系统首先把所有线程中断,然后让不在安全点的线程继续执行到安全点。
- 主动式中断(Voluntary Suspension)。每个线程主动轮询一个标志位,主要需要中断就自己在最近的安全点上主动中断刮起。
安全区域
安全点无法解决长时间没有分配的线程(sleep/block),所以引入安全区域来解决这个问题。
安全区域指能够确保在一端代码片段中,引用关系不会发生变化。这样在安全区域的任意地方都可以开始垃圾收集
记忆集与卡表
记忆集是为了解决对象跨代引用所带来的问题,避免把引用所在区域也加入到GC Roots扫描范围。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
卡表是记忆集的一种实现,记录一块内存区域内对象是否含有跨代指针。HotSpot种通过写屏障(Write Barrier)技术维护卡表的状态。
垃圾收集器
CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法。它包括4个步骤:
- 初始标记。stop the world,标记GC Roots能直接关联到的对象
- 并发标记。根据GC Roots直接关联到的对象并发标记对象
- 重新标记。stop the world,修正并发标记期间因用户程序运行而导致标记产生变动的对象的标记记录
- 并发清除。
CMS收集器的问题
- 无法处理“浮动垃圾”,只能等待下次GC。浮动垃圾是指并发标记和并发清除期间产生的新对象
- 并发标记期间用户线程仍然在运行,所以需要为用户线程预留一定的内存,所以不能在内存达到100%时才进行垃圾回收。CMS为这个百分比提供了一个参数。太低的百分比会增加垃圾回收的频率,太高的内存有可能导致运行期间用户线程无法获得内存,导致并发失败,降低性能。此时虚拟机会停止用户线程,并采用Serial Old收集器重新进行老年代垃圾收集。
CMS为了解决标记-清除算法空间碎片过多的问题,提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,用于在CMS收集器顶不住要进行FullGC时进行内存碎片合并整理,合并整理期间无法并发。
- CMS基于‘标记-清除’算法会产生内存碎片,导致给大对象分配内存时出现问题,导致必需进行一次Full GC。为了解决这个问题,CMS提供参数是否在FullGC前对内存顺便进行合并整理,由于内存碎片的整理无法并发,所以会影响性能。所以CMS提供了另一个参数用于配置CMS每Full GCn次就整理一次内存碎片。
G1收集器
G1收集器将整个Java堆划分为多个大小相等的独立区域。新生代和老年代不再是物理隔离,它们都是一部分不连续的区域的集合。G1收集器跟踪各个区域的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域。这种使用区域划分内存空间以及优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率,同时也可以控制停顿时间。
尽管G1收集器将内存划分为不同的区域,但这并不是说可达性分析时每个区域可以单独分析,不同区域的对象也会有相互引用。所以,如果没有好的设计,在对一个区域进行可达性分析时仍然需要对整个堆进行可达性分析。为了避免全堆扫描,虚拟机使用Remembered Set来避免全堆扫描。G1收集器的每个区域都有一个Remembered Set,虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不用的区域中。如果是,便通过CardTable把相关引用信息记录到被引用对象所属区域的Remembered Set中。当进行内存回收时,在GC roots枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
G1收集器的运作大致是四个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收。更新区域统计数据,堆回收价值和成本进行排序,根据停顿时间指定回收计划,然后回收指定的区域。
Shenandoah收集器
- 目标:任何堆内存下都可以把垃圾收集的停顿时间限制在10毫秒以内的垃圾收起
- 连接矩阵代替了记忆集
内存分配与回收策略
- 对象优先在Eden区域分配。如果Eden区域没有足够的空间,将发起Minor GC
- 大对象直接进入老年代
- 长期存活的对象进入老年代
- 动态对象年龄判定。虚拟机并不是永远要求对象年龄必须达到MaxTenuringThreshold才能晋升老年代,如果Survivor空间低于或等于某年龄的对象大小大于Survivor空间一半,年龄大于/等于该年龄的对象就可以直接进入老年代
- 空间分配担保。在Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果大于,则MinorGC是安全的。如果小于,虚拟机会查看HandlePromotionFailure设置睡否允许担保失败。如果允许,虚拟机会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试Minor GC(有风险)。如果小于或HandlePromotionFailure不允许担保失败,则进行FullGC。有风险的原因:极端情况下新生代的所有对象又要进入老年代,如果老年代没有足够的空间坑你会导致GC失败。
垃圾收集器参数总结
参数 | 描述 |
UseSerialGC | 打开后虚拟机使用Serial+Serial Old的收集器组合进行内存回收。虚拟机运行在Client模式下的默认值 |
UseParNewGC | 打开后使用parNew+Serial Old收集器组合进行内存回收 |
UseConcMarkSweepGC | 打开后使用ParNew+CMS+Serial Old组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用 |
UseParallelGC | 虚拟机运行在Server模式下的默认值。使用Parallel Scavenge + Serial Old组合进行内存回收 |
UseParallelOldGC | 使用Parallel Scavenge + Parallel Old组合进行内存回收 |
SurvivorRatio | 新生代中Eden区域和Survivor区域的容量比,默认是8,代表Eden:Survivor=8:1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小 |
MaxTenuringThreshold | 新生代的对象晋升到老年代的年龄 |
UseAdaptiveSizePolicy | 动态调整Java堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代的剩余空间不应付新生代Eden和Survivor的所有对象存活的极端情况 |
ParallelGCThreads | 设置并行GC时进行内存回收的线程数 |
GCTimeRatio | GC时间占总时间的比率,默认99。仅使用Parallel Scavenge时生效 |
MaxGCPauseMillis | 设置GC最大停顿时间。仅使用Parallel Scavenge时生效 |
CMSInitiatingOccupancyFraction | CMS收集器在老年代空间被使用多少后触发垃圾收集,默认为68% |
UseCMSCompactFullCollection | 设置CMS收集器在完成垃圾收集后是否进行内存碎片整理 |
UseFullGCsBeforeCompaction | 设置CMS收集器在完成多次垃圾收集后再启动一次内存碎片整理 |
JDK命令行工具
名称 | 作用 |
jps | JVM Process Status Tool,显示系统内的所有HotSpot虚拟机 |
jstat | JVM Statistics Monitoring Tool,用于收集HotSpot虚拟机个方面的运行数据 |
jinfo | Configuration Info for Java,显示虚拟机配置信息 |
jmap | Memory Map for Java,生成虚拟机的内存转储快照(headdump文件) |
jhat | JVM Heap Dump Browser,用于分许heapdump文件,他会建立一个HTTP/HTML服务器,让用户可以在浏览器上查看分析结果 |
jstack | Stack Trace for Java,显示虚拟机的线程快照 |
常用参数
- jps -mlv
- jstat -gc <pid> 1000
- jstat -compiler <pid>
- jstat -class <pid>
- jinfo <pid>
- jmap -heap <pid>
- jmap -dump:format=b,file=tmp.dump <pid>
- jstack <pid>
程序编译与运行优化
程序编译
Java的编译可以有3种理解:
- javac把java文件编译为class文件
- 运行期的JIT编译期把字节码编译为机器码
- 静态提前编译器(AOT)直接把java文件编译为机器码
javac的编译过程大致分为3个过程:
- 词法、语法分析与填充符号表
- 插入式注解处理器的注解处理
- 语义分析、解语法糖、字节码生成
Java中的语法糖有:泛型、变长参数、自动装箱拆箱、遍历循环、内部类、枚举类、断言语句
类型擦除的缺点:
- 由于不支持int,long与Object之间的强制转换,所以无法支持基本类型的泛型。导致了大量的自动装箱拆箱
- 运行期无法取到泛型的类型信息,导致代码变得啰嗦
后端编译与优化
- AOT(Ahead Of Time)提前编译
- JIT(Just In Time)即时编译
JIT编译器
运行期将“热点代码”编译为本地机器码,并进行各种优化。HotSpot内置了3个编译:C1(客户端编译器)、C2(服务端编译器)、Graal编译器。
JDK7之后默认采用分层编译:
- 程序纯解释执行,并且解释器不开启性能监控功能;
- 使用客户端编译器,运行简单可靠的稳定优化,不开启性能监控功能;
- 使用客户端编译器,仅开启方法集回边次数统计等有限的性能优化;
- 使用客户端编译器,开启全部性能监控,还有收集分支跳转、虚方法调用版本等全部统计信息;
- 使用服务端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
热点代码
- 被多次调用的方法;
- 被多次执行的循环体;(栈上替换 On Stack Replacement,OSR)
热点探测的判定方式
- 基于采样的热点探测
- 基于计数器的热点探测。HotSpot 采用的这种方式,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器。
编译器优化技术
- 方法内联
- 逃逸分析。分为线程逃逸和方法逃逸。如果一个对象没有发生方法/线程逃逸,比如局部变量,那肯定不会发生竞争,对这个变量实施的同步措施也就可以消除掉。
- 栈上分配
- 标量替换
- 同步消除(锁消除)
- 公共子表达式消除。同一个表达式出现多次,并且变量没有发生过变化,则称为公共子表达式
- 数组边界检查消除
- 编译期检查
- 隐式异常处理
Java内存模型
Java内存模型规定了所有变量都存储在主内存中。每个线程有自己的工作内存,线程的工作内存中保存了该被线程使用的变量(或变量的某个字典)的主内存副本,线程对变量的操作都必需在工作内存中进行,不能直接操作主内存数据。不同线程间也无法直接访问对方的工作内存,必需通过主内存来完成。
volatile变量
volatile变量特性:
- 保证此变量对所有线程的可见性。可见性指一个线程修改了这个变量的值,新值对其它线程立即可知。
- 禁止指令重排序。如果是普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码顺序一致
但是对volatile变量的运算并不是线程安全的,因为Java中的运算操作符号并不是原子操作,它是由多个字节码指令组成的,而字节码指令又可能会被解释为多个机器指令,机器指令都不一定是原子操作,所以对volatile变量的运算不是线程安全的。
long和double变量“非原子性协定”
Java内存模型中定义了8种操作来完成主内存和工作内存的操作,每个操作都是原子的。但是对long和double这两个64位数据允许分两次32位操作来进行。所以如果long,double会有多线程访问的时候可能导致获取到中间值。
happen before原则
如果两个操作不能满足happen before原则中的规则,那么虚拟机就可以对它们进行重排序。
- 程序次序规则:一个线程内按控制流顺序前面的操作先行发生于后面的操作
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对volatile变量的写操作先行发生于对这个变量的读操作
- 线程启动规则:Thread对象的start方法先行发生于此线程的每个动作
- 线程终止规则:线程中所有操作都先行发生于对此线程终止之前
- 线程中断规则:对interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize方法开始
- 传递性
线程
线程调度算法
- 先到先服务
- 最短优先
- 优先级调度算法
- 优先级调度算法
- 高响应比优先调度算法
- 时间片调度
- 多级反馈队列调度算法
- 电梯调度算法
java协程
loom项目
线程安全与锁优化
线程安全的实现方法
- 互斥同步(悲观并发策略)。同步是指共享数据在同一时刻只被一个线程访问。互斥是实现同步的一种方式,实现方式有临界区、互斥量、信号量。
- Java中的互斥语法就是synchronized,它会在同步块前后加上两个字节码指令:monitorenter、monitorexit。
- Lock接口。ReentrantLock的特点:等待可中断、公平锁、锁绑定多个条件。
- 缺点:互斥同步是阻塞同步,线程阻塞和唤醒会带来性能开销
- 非阻塞同步(乐观并发策略)。非阻塞同步需要支持原子性的硬件指令的支持
- CAS(ABA问题)
- swap指令
锁优化
- 自旋锁/自适应自旋锁。自旋锁需要硬件有多个处理器,并且自旋会占用处理器资源。
- 锁消除
- 锁粗化。比如在循环体内加锁
- 轻量级锁。轻量级锁是相对于使用操作系统的互斥量实现的重量级锁
- 偏向锁
HotSpot虚拟机的对象头分为两部分,第一部分Mark Word用于存储对象自身的运行时数据,如哈希码、GC分代年龄,第二部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会存储数组长度。
Mark Word被设计为动态数据结构,对象在不同的状态下同一位表示的含义不同,Mark Word也被标记对象锁的状态。
偏向锁
偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。当锁对象第一次被线程访问的时候,虚拟机会把对象头重的标志位设为01,把偏向模式设置为1,表示进入可偏向模式。同时使用CAS将当前线程id保存在Mark Word中。当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换Mark Word中的线程ID为自己的ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁就会撤销,将升级为轻量级锁。
偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。
因为对象的hashCode是存储在mark word的所以,一个对象一旦计算过hashcode,就不再能进入偏向模式。如果在偏向模式调用hashcode时,它的偏向模式会立即撤销。
轻量级锁
当另一个线程访问这个同步代码或方法时,如果此同步对象没有被锁定(锁标志位是01),虚拟机首先在当前线程的栈帧中存储锁对象的Mark Word的拷贝,然后使用CAS将Mark Word更新为指向拷贝的指针。如果更新成功,表示线程获得该锁对象,然后将锁标志位改为00,表示获得轻量级锁。如果更新失败,表示存在别的线程与当前线程竞争获取该锁对象。虚拟机会先检查Mark Word的指针是否指向当前线程的栈帧。如果是,说明当前线程已经获得了该对象锁,否则说明是其它线程抢占了。那么锁就要膨胀为重量级锁,锁状态变为10,Mark Word存储的是指向重量级锁的指针。已经获得轻量级锁的线程在解锁时,同样使用CAS,如果失败说明别的线程获取过锁,锁已经进入重量级锁,那么需要在释放锁的同时,唤醒被挂起的线程。