根据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 <class文件> |
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文件,并将字节码保存在内存中的方法区。
JDK9之后为了实现模块化:
类加载器加载类的三个关键字:委托机制、可见性、唯一性。
Application ClassLoader
类加载器进行加载;Application ClassLoader
又会委托Extension ClassLoader
进行加载;Extension ClassLoader
又会委托Bootstrap ClassLoader
进行加载。类加载的触发条件
不会触发类加载的示例:
类加载器从加载类到虚拟机内存到卸载,整个生命周期包括:加载、连接(验证、准备、解析)、初始化、使用、卸载。
文件格式验证
验证是否符合类文件结构
元数据验证
验证父类是否存在、是否继承了不允许继承的类(final class)
字节码验证
验证方法体的语法是否正确
符号引用验证
符号引用验证发生在虚拟机将符号引用转化为直接引用的时候,这个动作在解析阶段发生。
虚拟机为类变量分配内存并设置类变量初始值(零值)
虚拟机将常量池内的符号引用替换为直接引用。需要解析的符号引用有:类或接口、字段、类方法、接口方法
符号引用:以一组符号描述所引用的目标,可以是任何形式的字面量
直接引用:直接指向目标的指针、相对偏移量、能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标一定已经在内存中了。
执行类中定义的字节码。
执行类构造器<clinit>()方法。<clinit>()方法由编译器自动收集类的类变量的赋值动作和静态语句快中的语句合并产生的。虚拟机会保证<clinit>()方法在多线程环境中被正确的加锁、同步,<clinit>()只会被执行一次。
方法的调用分为两种:解析和分派。在JVM中提供了5条方法调用字节码指令:
在类加载的解析阶段,会将其中一部分方法的符号引用转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法的调用称为解析。解析调用是一个静态的过程,在编译期就可以完全确定,在类装载的解析阶段就会把设计的符号引用全部转变为直接应用,不会延迟到运行期完成。
在Java中符合“编译器可知,运行期不可变”条件的方法主要包括静态方法和私有方法。静态方法与类型直接关联,私有方法不能被外部访问,所以这两种方法都不能被重写。
分派调用可能是静态也可能是动态的,分派可以分为单分派和多分派。所以分派分为:静态单分派、静态多分派、动态单分派、动态多分派。
依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译阶段。
基本类型的自动转型:char->int->long->float->double
依赖动态类型来定位方法执行版本的分派动作称为动态分派,动态分派的典型应用是方法重写。动态分派发生在运行阶段。动态分派编译后的指令时invokevirtual。它会根据对象的真正类型从子类到父父类依次寻找对应的方法。
分派调用的优化手段:虚/接口方法表、内联缓存、基于“类型继承关系分析”技术的守护内联。
Java虚拟机的指令由一个字节长度的数字(操作码)以及随后跟着的多个参数(操作数)构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数指令的都不包含操作数,只有一个操作码。
由于class文件格式放弃了编译后代码的操作数长度对齐,所以虚拟机在处理超过1个字节数据时,必须在运行时从字节中重建具体数据的结构。如果要将一个16位长度的无符号整数使用两个字节存储,那么它的值应该是
(byte1<<0)|byte2
。这种操作在导致解释执行字节码时损失一些性能。但这样做的优势是放弃了操作数长度对齐,可以省略很多填充和间隔符号。
指令的分类:
一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,每个线程都有独立的程序计数器
每个线程都有独立的栈,是Java方法执行的内存区域,每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。当调用一个方法时,就会有一个栈帧入栈,当方法调用完成之后,对应的栈帧就出栈。
局部变量表存放了编译期就已经知道的基本数据类型和复杂对象的引用。其中64位长度的long和double会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表需要的内存大小在编译完成后就已经确定,在方法运行期间不会改变局部变量表的大小。
虚拟机栈的两种异常状况:StackOverflowError、OutOfMemoryError
存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Varialbe Slot)为最小单位,每个变量槽都可以存放boolean、byte、char、short、int、float、reference、returnAddress类型。
变量槽可以复用。如果某个变量不会再被访问到,后面又有新的变量赋值则可能可以复用变量槽。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。字节码中的符号引用在类加载阶段或第一次使用就转化为直接引用,这种转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这部分称为动态连接。
执行Native方法时的栈
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的缓存区就可以申请直接内存。
对象在内存中分为3个区域:对象头、实例数据、对齐填充
对象头包括Mark Word和类型指针。Mark Word用于存储对象自身的运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向ID、偏向时间戳。
虚拟机遇到new操作指令时,
首先检查方法区,是否能在常量池中定位到类的符号引用,并检查类是否已经加载完成;
然后虚拟机为对象分配堆内存,一个对象需要多大的内存在类加载完成后就可以确定下来;
然后虚拟机分配到的内存空间初始化为0(不包括对象头)
然后虚拟机初始化对象的对象头。对象头只要保存对象锁属的类、对象的哈希吗、对象的GC分代年龄、对象的锁信息。
然后执行
堆内存是否规整是由虚拟机采用的垃圾收集器是否带有压缩整理功能决定的。因此在使用Serial、PraNew等带Compact过程的收集器时,采用的指针碰撞方式,而使用CMS这种基于标记、清除算法的收集器时,采用的是空闲列表方式。
对象创建在虚拟机中是非常频繁的行为,这个操作不是线程安全的,可能出现正在给对象A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题的方式有两种:
对强引用对象,在可达性分析中要经历至少两次标记过程。如果在第一次可达性分析中对象到GC Roots不可达,如果对象的finalize被方法没重写且没有被虚拟机调用过,那么会将对象放入F-Queue队列中等待执行finalize方法。如果第二次可达性分析对象仍然不可达,那么对象将会回收,否则对象不会被回收。
方法区所有的常量很多,遍历整个方法区太慢,HotSpot使用OopMap数据结构来维护根节点,HotSpot为字节码指令(不是所有的指令)生成OopMap。
HoySpot不会为每条指令生成OopMap(否则会需要大量存储空间),只有在安全点生成OopMap,同时也意味着只有虚拟机所有线程都到达安全点,才能开始垃圾收集。
安全点不能太少以至于让收集器等待太长时间,页不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都很短,程序不太可能因为指令流太长而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,所以只有具有这些功能的指令才会产生安全点
让程序在安全点停顿下来有两种方案:
安全点无法解决长时间没有分配的线程(sleep/block),所以引入安全区域来解决这个问题。
安全区域指能够确保在一端代码片段中,引用关系不会发生变化。这样在安全区域的任意地方都可以开始垃圾收集
记忆集是为了解决对象跨代引用所带来的问题,避免把引用所在区域也加入到GC Roots扫描范围。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
卡表是记忆集的一种实现,记录一块内存区域内对象是否含有跨代指针。HotSpot种通过写屏障(Write Barrier)技术维护卡表的状态。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法。它包括4个步骤:
CMS收集器的问题
G1收集器将整个Java堆划分为多个大小相等的独立区域。新生代和老年代不再是物理隔离,它们都是一部分不连续的区域的集合。G1收集器跟踪各个区域的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域。这种使用区域划分内存空间以及优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率,同时也可以控制停顿时间。
尽管G1收集器将内存划分为不同的区域,但这并不是说可达性分析时每个区域可以单独分析,不同区域的对象也会有相互引用。所以,如果没有好的设计,在对一个区域进行可达性分析时仍然需要对整个堆进行可达性分析。为了避免全堆扫描,虚拟机使用Remembered Set来避免全堆扫描。G1收集器的每个区域都有一个Remembered Set,虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不用的区域中。如果是,便通过CardTable把相关引用信息记录到被引用对象所属区域的Remembered Set中。当进行内存回收时,在GC roots枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
参数 | 描述 |
---|---|
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收集器在完成多次垃圾收集后再启动一次内存碎片整理 |
名称 | 作用 |
---|---|
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的编译过程大致分为3个过程:
Java中的语法糖有:泛型、变长参数、自动装箱拆箱、遍历循环、内部类、枚举类、断言语句
类型擦除的缺点:
- 由于不支持int,long与Object之间的强制转换,所以无法支持基本类型的泛型。导致了大量的自动装箱拆箱
- 运行期无法取到泛型的类型信息,导致代码变得啰嗦
运行期将“热点代码”编译为本地机器码,并进行各种优化。HotSpot内置了3个编译:C1(客户端编译器)、C2(服务端编译器)、Graal编译器。
JDK7之后默认采用分层编译:
Java内存模型主要解决多个线程对内存中同一变量访问时的一致性问题。所以,Java内存模型的主要目的是定义程序中各个变量的访问规则,即关注在虚拟机中把变量存储到内存和充内存取出变量值的底层细节。这里的变量是指会被多线程访问的所有变量:实例字段、静态字段和构成数组对象的元素,不包括局部变量。
Java内存模型规定了所有变量都存储在主内存中。每个线程有自己的工作内存,线程的工作内存中保存了该被线程使用的变量(或变量的某个字典)的主内存副本,线程对变量的操作都必需在工作内存中进行,不能直接操作主内存数据。不同线程间也无法直接访问对方的工作内存,必需通过主内存来完成。
volatile变量特性:
但是对volatile变量的运算并不是线程安全的,因为Java中的运算操作符号并不是原子操作,它是由多个字节码指令组成的,而字节码指令又可能会被解释为多个机器指令,机器指令都不一定是原子操作,所以对volatile变量的运算不是线程安全的。
Java内存模型中定义了8种操作来完成主内存和工作内存的操作,每个操作都是原子的。但是对long和double这两个64位数据允许分两次32位操作来进行。所以如果long,double会有多线程访问的时候可能导致获取到中间值。
如果两个操作不能满足happen before原则中的规则,那么虚拟机就可以对它们进行重排序。
loom项目
互斥同步(悲观并发策略)。同步是指共享数据在同一时刻只被一个线程访问。互斥是实现同步的一种方式,实现方式有临界区、互斥量、信号量。
缺点:互斥同步是阻塞同步,线程阻塞和唤醒会带来性能开销
非阻塞同步(乐观并发策略)。非阻塞同步需要支持原子性的硬件指令的支持
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,如果失败说明别的线程获取过锁,锁已经进入重量级锁,那么需要在释放锁的同时,唤醒被挂起的线程。
原文:https://www.cnblogs.com/xiaoyuanr/p/13791669.html