内存模型
Java内存模型主要分为5块:
- 程序计数器,线程私有,记录当前线程执行方法的指令地址,如果执行Java方法,记录虚拟机字节码指令的地址,如果是Native方法,计数器值为空
- 方法区:线程共享,存储类的相关信息,常量,静态变量、代码等
- Java虚拟机栈,线程私有,执行每个方法会有对应的栈帧,里面存储了局部变量表、操作数栈、动态链接、方法出口等信息等
- 局部变量表:存放编译器可知的各种基本数据类型
- 操作数栈:保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 动态链接(帧数据区):保存着访问常量池的指针
- 方法出口
- 本地方法栈,类似Java虚拟机栈,服务于Native方法
- java堆,线程共享,存储具体的对象实例
引用类型
- 强引用:有引用存在永远不会被回收
- 软引用:在内存溢出之前,进行GC后,如果仍没有足够内存,会进行回收
- 弱引用:每次GC都会回收
- 虚引用:不对生存时间构成影响,设置虚引用的唯一目的是在这个对象被GC时收到一个系统通知
安全点
一方面,GC可以通过OopMap,快速得到执行上下文和全局的引用位置,完成GC Roots枚举。
另一方面,通过在诸如方法调用、循环跳转、异常跳转等安全点才暂停进行GC,减少OopMap的生成成本。
GC发生时,通过抢先式中断和主动式中断方法,让所有线程都跑到最近的安全点。
- 抢占式中断:首先让所有线程中断,对于不在安全点的线程,恢复让其跑到安全点。(很少采用)
- 主动式中断:当需要中断时,进行简单地标记,各个线程执行时主动地轮询这个标志,发现标志为真就自己中断刮起。其中轮询标志的地方和安全点是重合的。
安全区域
在安全区域(一段代码片段)中的引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。线程在执行到Sage Region时进行标志,在离开时,检查是否完成根结点枚举(或整个GC),如果未完成,需要等待直到收到可以安全离开安全区域的信号,以此来解决在GC时,处于Sleep或Blocked的线程无法走到安全点的问题。
垃圾回收
根据GCRoot可达性分析和标记对象,可以作为GC Roots的对象有:
- 虚拟机栈的局部变量表引用的对象
- 方法区中类静态属性、常量引用的对象
- 本地方法栈中JNI引用的对象
在此基础上,常用算法有:
- 标记清除法:标记和清除效率不高,会存在不连续的内存碎片
- 复制算法,将内存区域分为两块,每次将存活对象从一块复制到另一块,解决了内存碎片问题,但内存使用效率低,新生代大多数对象存活期很短,可以使用复制算法,但老年代效率会很低
- 标记整理法,在标记出存活对象后,直接将存活对象整理到一起,相对标记清除慢一些,但减少了内存碎片
垃圾回收器
- 新生代:serial,parnew,parallel Scavenge
- 老年代:serial old, parallel old
serial
单线程收集 STW
ParNew收集器
多线程收集,可以和CMS收集器配合工作
Parallel Scavenge(吞吐量优先)
目标在于达到一个可控制的吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间)
可以通过参数直接控制最大垃圾收集停顿时间和设置吞吐量大小
Serial Old 单线程收集,使用标记整理算法,可以与Parallel Scavenge搭配,或作为CMS收集器的后备预案
Parallel Old
标记-整理,可以与Parallel Scavenge搭配
cms
关注点是尽可能地缩短垃圾收集时用户线程停顿时间
其中cms运作过程分为6块,包括:
- 初始标记:STW操作(停顿时间短),只扫描能够和“根对象”直接关联的对象,并作标记。
- 并发标记:在初始标记基础上向下追溯标记,和用户线程并发操作,不用停顿
- 并发预清理:查找在执行并发标记阶段新进入老年代的对象,通过重新扫描,减少下一个阶段"重新标记"的工作
- 重新标记:STW,扫描在CMS堆中剩余的对象。扫描从"根对象"开始向下追溯,并处理对象关联
- 并发清除:清理垃圾对象,这个阶段收集器线程和应用程序线程并发执行
- 并发重置:重置CMS收集器的数据结构,等待下一次垃圾回收。
cms和full gc
- Full GC的次数 = 老年代GC时 stop the world的次数
- Full GC的时间 = 老年代GC时 stop the world的总时间(和用户线程并发的gc不算)
- Full GC本身不会先进行Minor GC,我们可以配置,让Full GC之前先进行一次Minor GC,因为老年代很多对象都会引用到新生代的对象,先进行一次Minor GC可以提高老年代GC的速度
g1
- 分很多等大的内存连续区,通过标记优先回收效益最高的区,根据停顿事件要求来灵活选择需要回收的区数量。
- 有特殊的数据结构 Remembered Set,从而避免耗时的全堆扫描。他的实现原理是虚拟机在程序对引用对象进行写操作时,会产生一个写屏障中断检测是否引用跨区,如果是就用卡表跟踪指向不同堆区的对象引用记录在被引用对象所属Region的Remembered set中,回收的时候,只需要GCRoot递归检查RSet即可保证不用全堆扫描也不会又遗漏
内存分配和回收策略
- 对象优先在Eden分配,空间不够时触发Minor GC
- 大对象直接进入老年代,避免在Eden和两个Survivor区进行大量的内存复制
- 长期存活的对象将进入老年代。在Survivor区每存活多一次GC,年龄+1,达到一定程度后将晋升老年代。
- 动态对象年龄判定:如果在Survivor空间中相同年龄所有对象大小综合大于Survivor空间的一般,年龄大于等于该年龄的对象就可以直接进入老年代
永久代垃圾回收
主要回收废弃常量和无用的类。无用的类:
- 该类所有的实例都已经被回收,Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,或被通过反射访问类方法
类加载机制
加载
首先明确:“加载”只是“类加载(Class Loading)”过程的一个阶段。
在加载阶段,虚拟机需要完成一下3件事情
- 通过一个类的全限定名来获取定义此类的二进制字节流(如从ZIP包读取,构建JAR、WAR等包格式、从网络读取,Applet应用、运行时计算生成,动态代理技术)
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。
验证
验证是链接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。主要是为了防止Class文件不合法生成,从而载入了有害的字节流而导致系统崩溃。
具体包括:
文件格式验证
验证字节流是否符合Class文件格式的规范,比如有:
- 是否以魔数(0xCAFEBABE)开头
- 主次版本号是否在当前虚拟机处理范围内
- 常量池是否有不被支持的常量类型
元数据验证
对字节码描述的信息进行语义分析,已保证其描述的信息符合Java语言规范,比如有:
- 这个类是否有合法父类(除了Object外,都应有父类)
- 某个类是否继承了不允许被继承的类(final修饰)
- 如果这个类不是抽象类,是否实现了父类或接口中要求实现的方法
字节码验证
主要目的是通过数据流和控制流分析,确定程序语义是否合法、符合逻辑。比如:
- 保证操作数栈的数据类型与指令代码序列都能配合工作
- 保证跳转指令不会跳转到方法体外的字节码指令上
- 保证方法体中的类型转换都是有效的
符号引用验证
这个校验发声在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在链接的第三阶段,解析阶段,目的是确保解析动作能正常执行。通常会校验
- 符号引用中通过字符描述的全限定名是否能找到对应的类
- 在制定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的访问是否可被当前类访问
准备
转杯阶段是正式为类变量分配内存并设置类变量初始化值的阶段,这些变量所使用的内存都在方法区中进行分配。这里进行内存分配的进包括静态类变量,而不包括实例变量,实例变量将在对象实例化时随对象一起分配在Java堆中。
另一方面,初始值是指数据类型的零值(默认值),如:
public static int value=1
在准备阶段,会被初始化为0,为1的赋值操作会在初始化阶段才执行
而如果类字段的字段属性表中存在ConstantValue属性,即假如上面改为:
public static
final
int value = 1
,则在准备阶段就会被赋值为1
解析
解析是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用以一组符号来描述所引用的目标,它是一个包含足够定位信息的字符串,实际使用时可以找到相应的位置。比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。当第一次运行时,要根据字符串的内容,到该类的方法表中搜索这个方法。
运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。直接引用就是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。
初始化
类加载的初始化阶段对类变量赋予正确的值。主要有两种初始化方式,一种是通过类变量初始化语句;一种是静态初始化语句。如调用类构造器<clinit>
<clinit>()
方法
所有的类变量初始化语句和静态初始化语句都被Java编译器收集在一起,放在一个特殊方法里。对于类而言,该方法称为类初始化方法,对于接口而言,该方法称为接口初始化方法。在Java class文件里,类和接口的初始化方法统一被称作为() 方法。
并且这种方法只能被Java虚拟机调用,Java程序是无法调用的。
并非每个类都拥有()方法,以下三种情况就没有:
- 类没有申明类变量,也没有任何静态初始化语句;
- 类申明了类变量,但是没有任何的类变量初始化语句,页没有静态初始化语句进行初始化;
- 类近包含静态final变量的类变量初始化语句,而且是编译时候的常量;
另一方面,初始化类的过程必须保持同步,如果有多个线程初始化一个类,仅仅允许一个线程执行初始化,其他的线程都需要等待。
类的解析阶段不一定按序进行,在某些情况下可以在初始化阶段之后开始,以支持Java语言的运行时绑定(动态绑定、晚期绑定)。 这些阶段通常都是互相交叉地混合式进行,通常会在一个阶段执行的过程中调用、激活另外一个阶段。
类初始化时机:
虚拟机规范严格规定了有且只有5种情况必须对类进行“初始化”(加载、验证、准备需要在此之前):
- 使用new关键字实例化对象的时候、读取或设置一个类的静态字段的时候(被final修饰,已在编译器把加过放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先出初始化这个类
- 使用JDK1.7的相关动态语言支持
以上5个场景都是对一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。 包括以下场景
- 通过子类引用父类的静态字段,不会导致子类初始化,对于静态字段访问,只有直接定义这个字段的类才会被初始化。
- 同故宫数组定义来引用类,不会触发此类的初始化,如定义:
MyClass [] classes = new MyClass[10];
,这种时候不会真正对类进行初始化 - 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。如定义:
public class MyClass{
public static final String TEST = "test";
}
当通过MyClass访问常量TEST时,MyClass不会被初始化。
通过子类调用父类的静态字段,只会触发父类初始化
类卸载条件
- 该类的所有实例都被回收
- 加载该类的ClassLoader也被回收了
- 对应的Class对象没有任何地方被引用,即无法在任何地方通过反射访问该类的方法
类加载器
在java.lang包下有一个抽象类ClassLoader,它通过我们给定的类的全限定名,找到对应的Class字节码文件,然后加载它转化为一个java.lang.Classs类的一个实例。
系统提供了三种类加载器,包括
启动类加载器
这个类加载器负责将<JAVA_HOME>\lib
目录下的类库加载到虚拟机内存中,用来加载java的核心库,此类加载器并不继承于java.lang.ClassLoader,不能被java程序直接调用,代码是使用C++编写的.是虚拟机自身的一部分.
扩展类加载器(Extendsion ClassLoader):
这个类加载器负责加载<JAVA_HOME>\lib\ext
目录下的类库,用来加载java的扩展库,开发者可以直接使用这个类加载器.
应用程序类加载器(Application ClassLoader)/系统类加载器:
这个类加载器负责加载用户类路径(CLASSPATH)下的类库,一般我们编写的java类都是由这个类加载器加载,这个类加载器是CLassLoader中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器.一般情况下这就是系统默认的类加载器.
双亲委派模型
他们通过对象组合形成父子关系,当查找某个类的类加载器时,会从上下文类加载器开始查找,如果找不到再到父类去找,而加载类的顺序相反,先看父类能否加载,父类不能加载则由子类进行加载。
使用这种工作机制的优势在于,每个类的创建层级分明,并且能够唯一找到对应优先级最高的加载器去创建,不会因为同一个类(比如java.lang.Object)通过不同类加载器创建,从而得到多个不同的(Object)类,造成程序混乱。
如基础类需要调用用户类,需要破坏双亲委派模型。
在springboot的devtools中。用来双类加载机制,一个是用于加载不会改变的第三方Jar包的Base Class Loader 另一个则是加载我们自己编写的类的restart class loader,当代码修改后,会通过重启类加载重新加载我们的类。在使用Dubbo的时候,rpc层会提示找不到代理类,原因是查找类的时候,是从当前上下文的应用类加载器,而加载我们代理类的类加载器是devtools的restart class loader,位于应用类加载器的下层,因而无法找到
内存溢出故障
只发生在jvm对老年代和永久带回收后仍不能获得足够内存的时候
死锁检测
jconsole,jstack
内存泄露
- jmap -heap:file=heap.bin
- 使用Eclipse Memory Analyzer
对象头
- mark word 存储对象自身的运行时数据,包括哈希码25bit,GC分代年龄4bit,锁标志位2bit,最后1bit固定为0。
锁优化
- 自旋锁与自适应自旋
- 锁消除,对被监测到不可能存在共享数据竞争进行消除
- 锁粗化,针对一系列的连续加锁解锁操作,可对锁进行粗化
- 轻量级锁,在无竞争的情况下使用CAS操作去消除同步使用的互斥量
- 通过CAS和线程建立锁关系,cas成功则获得锁,如果cas失败,判断,对象的mark Word是否指向当前线程的栈帧,则进入同步块执行,否则说明锁对象被其他线程抢占,如果有两条以上线程争用,会膨胀成重量级锁。后面等待锁的线程进入阻塞
- 解锁时占有锁(CAS成功)的线程判断如果是重量级锁,会释放锁同时唤醒被挂起的线程。
- 偏向锁,在无竞争的情况下直接消除同步过程,包括CAS。
- 当锁对象第一次被线程获取,锁对象头标志位设为偏向模式,并case把线程id记录在MarkWord里。此时该线程可重入。
- 当有另一个线程尝试获取锁,会撤销偏向,恢复到未锁定或轻量级锁
平台无关性
“与平台无关”实现在操作系统的应用层上,通过实现可以运行在各种不同平台上的虚拟机,而这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现程序的“一次编写,到处运行”。
各种不同平台的虚拟机与所有平台都统一是使用的程序存储格式:字节码(ByteCode)是构成平台无关性的基石
语言无关性
现在有很多语言都可以运行在Java虚拟机之上,比如Clojure、Groovy、JRuby、Jython、Scala等等,从一定意义上,JVM实现了语言无关性
而实现语言无关性的基础仍然是JVM和字节码存储格式。Java虚拟机不合包括Java在内的任何语言绑定,之和“Class”文件这种特定的二进制文件格式关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其它辅助信息。JVM只关注有效的Class文件,而不关心CLass文件来源于何种语言,是由哪种语言编译而成。
Class文件结构
class文件头
4个字节的魔数(0XCAFEBABE)+ 2个字节的次版本号 + 2个字节的主板本号
常量池
在主次版本号后是常量池入口。
常量池中常量的数量不固定,故在常量池入口放置一个u2类型的数据,代表常量池容量计数值(constant_pool_count),这个容量技术从1开始。
常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
- 字面量:如文本字符串、声明为final的常量值等
- 符号引用:包括下列三类常量:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),用于区别一些类或者接口层次的访问信息,如这个Class是类还是接口;是否public、abstract等
类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,class由这三项数据来确定这个类的继承关系,类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,接口集合索引用来描述这个类实现了哪些接口,根据implements语句顺序从左到右排列。
字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。字段包括类级变量和实例级变量,但不包括方法内部声明的局部变量。字段表结构包括
- 访问标志(access_flags):包括public、private、protected、static、final等
- 名称索引(name_index):对常量池的引用,代表字段的简单名称
- 描述符索引(descriptor_index):对常量池的引用,代表字段和方法的描述符,描述符用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值,如byte(B)\char©\double(D)\void(V)\对象类型(L)等
- 属性表集合(attributes)
- 属性表数量(attributes_count)
方法表集合
方发表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes),属性表数量(attributes_count)几项。
属性表集合
属性表用于描述某些场景的专有信息,常用的有:
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方发表 | Java代码编译成的字节码指令 |
ConstantValue | 字段表 | finaluguanjianzi定义的常量值 |
Deprecated | 类、方法表、字段表 | 被生命为deprecated的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code属性 | Java源码的行号和字节码指令的对应关系 |
LocalVariableTable | Code属性 | 方法的局部变量描述 |
SourceFile | 类文件 | 源文件名称 |
Systhetic | 类、方法报、字段表 | 标识方法或字段为编译器自动生成的 |
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量表来表示,而属性值的结构则是完全自定义的,只要说明属性值所占用的位数长度即可。一个符合规则的属性表应该满足如下表定义的结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u2 | attribute_length | 1 |
u1 | info | attribute_length |
Java字节码指令
加载和存储指令
用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
运算指令
用于对两个操作数栈上的值进行某种特定的运算,如加减乘除等,计算完把结果会重新存入到操作栈顶中。
类型转换指令
可以将两种不同的数值类型进行相互转换,一般用于实现用户代码中显式类型转换
对象创建和访问指令
Java虚拟机对类实例和数组的常见与操作使用了不同的字节码指令。对象创建后,可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,常用有new等。
操作数栈管理指令
用于直接操作操作数栈的指令
控制转移指令
使Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序。
方法调用和返回指令
用于处理调用对象的实例方法、接口方法等操作。
异常处理指令
用于显式抛出异常等操作
同步指令
JVM支持方法级的同步以及方法内部一段指令序列的同步,这两种同步结构都使用管程(Monitor)来支持