JVM
Class文件结构
- 魔数:唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件。
- 版本号:确定Class文件编译器版本,低版本的虚拟机不能运行高版本编译器编译的Class文件。
常量池:
- 字面量
- 符号引用
访问标志:用于识别一些类或接口层次的访问信息。
- 是否final
- 是否public,否则是private
- 是否是接口
- 是否可用invokespecial字节码指令
- 是否abstract
- 是否是注解
- 是否是枚举
类索引、父类索引、接口索引集合:这三项数据主要用于确定这个类的继承关系。
- 类索引:用于确定这个类的全限定名
- 父类索引:用于确定这个类的父类的全限定名
- 接口索引:描述这个类实现了哪些接口
字段表集合
表结构
- 访问标志
- 名称索引
- 描述符索引
- 属性表集合
- 字段表用于描述接口或类中声明的变量,包括类级别(Static)和实例级别的变量,不包括在方法内部声明的变量;
- 简单来说,字段表集合储存字段的修饰符+名称;
- 变量修饰符使用标志位表示,字段数据类型和字段名称则引用常量池中常量表示。
方法表集合
- 访问标志
- 名称索引
- 描述符索引
- 属性表集合:Java代码经过编译器编译为字节码之后,储存在方法属性表中一个名叫
Code
的属性中。
- 属性表集合:在Class文件、字段表、方法表都可以携带子集的属性表集合,以用于描述某些场景专用的信息。
类加载机制
类生命周期
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
类加载器
- 启动类加载器:C++实现,是虚拟机自身的一部分;负责将存放在
<JAVA_HOME>/lib
目录中的类库加载到虚拟机内存中; - 其它加载器:由Java实现,独立于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader
; 分类:
- 启动类加载器
- 扩展类加载器:负责将
<JAVA_HOME>/lib/ext
或者被java.ext.dir
系统变量所指定路径中所有类库加载到内存中; - 应用程序类加载器:负责加载用户类路径
ClassPath
上所指定的类库; - 自定义类加载器:用户根据需求自己定义的类加载器,也需要继承自ClassPath。
双亲委派模型
- 内容:如果一个类加载器收到类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。
实现:
- 首先检查类是否被加载;
- 若未加载,则调用父类加载器的
loadClass
方法; - 若该方法抛出
ClassNotFoundException
异常,则表示父类加载器无法加载,则当前类加载器调用findClass
方法加载类; - 若父类加载器可以加载,则直接返回Class对象。
- 好处:保证Java类库中的类不受用户类影响,防止用户自定义一个类库中的同名类,引起问题。
破坏
基础类需要调用用户的代码:
- 解决方式:线程上下文类加载器:也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型;
- 实现方法:重写
ClassLoader
类的loadClass()
方法; 示例:
- JDBC:原生的JDBC中的类是放再
rt.jar
包的,是由启动类加载器进行类加载的,JDBC中的Driver
类中需要动态去加载不同数据库类型的Driver
类; - JNDI:JNDI服务需要调用由独立厂商实现并部署在应用程序的
ClassPath
下的JNDI接口提供者的代码。
- JDBC:原生的JDBC中的类是放再
- 重写
loadClass()
方法:双亲委派模型的具体实现就体现在loadClass()
方法中; 用户堆程序动态性的追求:
- OSGI(面向Java的动态模型系统),在OSGI环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构;
- 代码热替换、模块热部署。
- 典型的打破双亲委派模型的框架和中间件有:
Tomcat
和OSGI
。
- 模型要求除了底层的启动类加载器外,其余的类加载器都应有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。
类加载过程:
- 加载:将编译后的.Class静态文件转换到内存中(方法区),然后暴露出来让程序员能访问到;
- 验证:确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全;
- 准备:准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存;
- 解析:将Class文件的常量池的符号引用替换为直接引用的过程(是静态链接),可能发生在初始化阶段之前,也可能发生在初始化阶段之后,后者是为了支持Java的动态绑定。
- 初始化:为类的静态变量赋予程序中指定的初始值,还有执行静态代码块中的程序(执行
<cinit()>
方法)。
类加载方式:
- 命令行启动应用时候由JVM初始化加载;
- 通过
Class.forName()
动态加载; - 通过
ClassLoder.loadClass()
方法动态加载。
类加载时机:
- 遇到
new
、getStatic
、putStatic
、invokeStatic
这四条指令:new一个对象、调用一个类的静态方法、直接操作一个类的static属性; - 使用
java.lang.reflect
进行反射调用; - 初始化类时,没有初始化父类,先初始化父类;
- 虚拟机启动时,用户指定的主类(main);
虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放再运行时数据区的方法区内,然后在堆区创建一个java.lang.Class
对象,用来封装类在方法区内的数据结构。类的加载的最终产出是位于堆区的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区的数据结构的接口。
对象
对象的创建
- 根据new的参数能否在常量池中定位到一个类的符号引用,如果没有找到,说明还未定义该类,抛出ClassNotFoundException;
- 检查符号引用对应的类是否加载过,如果没有加载,则进行类加载;
- 根据方法区的信息确定为该类分配的内存空间大小;
从堆内存中分一块对应大小的内存空间给该对象;
- 指针碰撞,Java堆内存空间规整的情况下使用(Java堆内存是否规整由所采用的垃圾收集器是否带有压缩整理功能决定);
- 空闲列表,Java堆内存空间不规整的情况下使用。
- 对象中的成员变量赋初始值;
- 设置对象头信息;
- 调用对象的构造函数进行初始化。
对象的内存布局
对象数据分为三部分:对象头、对象的实例数据、对齐填充。
对象头主要分为两部分:Mark Word和Class Metadata Address。
其中Mark Word主要记录对象的hashcode、GC年代、锁信息(偏向锁、轻量级锁、重量级锁)、GC标志。
Class Metadata Address是指向类对象实例的指针。
对象的访问方式
- 指针:reference中储存的直接就是对象地址;
- 句柄:Java堆中将会划分出一块内存来作为句柄池,reference中储存的是对象的句柄地址,而句柄中包含了对象数据与类型数据各自的具体地址信息。
两种方式的比较:
- 使用直接指针方式访问的最大好处就是速度更快,它节省了一次指针定位的时间开销。HotSpot虚拟机使用的是直接指针访问方式;
- 使用句柄方式来访问的最大好处是reference中储存的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
内存结构
线程共享区域
堆(用于存放对象实例)
新生代
- Eden区
- Survivor(From)区:设置Survivor是为了减少送到老年代的对象
- Survivor(To)区:设置两个Survivor区是为了解决碎片化的问题
- Eden:Survivor:Survivor=8:1:1
- 老年代 老年代:新生代=2:1
方法区
运行时常量池
- Class的文件中的常量池(编译器生成的各种字面量和符号引用)会在类加载后被放入这个区域
储存信息
符号引用
符号引用包含的常量
- 类符号引用
- 方法符号引用
- 字段符号引用
概念解释
- 一个Java类(假设为People类)被编译成一个Class文件,如果People类引用了Tool类,但是在编译时People类并不指定引用类的实际内存地址,因此只能使用符号引用来代替;
- 而在类装载器装载People类时,此时可以通过虚拟机获取Tool类的实际内存地址,因此便可以将符号org.simple.Tool替换为Tool类的实际内存地址,及直接引用地址;
- 即在编译时用符号引用来代替引用类,在加载时再通过该引用类的实际地址;
- 以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局是无关的,引用的目标不一定已经加载到内存中。
字面量
- 文本字符串
String a = "abc"
,这个abc就是字面量 - 八种基本类型
int a = 1
,这个1就是字面量 - 声明为final的常量
- 文本字符串
- 静态变量
- final类型常量
类信息
- 类的完整有效名
- 返回值类型
- 修饰符(public、private)
- 变量名
- 方法名
- 方法代码
- 这个类型直接父类的完整有效名(除非这个类型是interface或是
java.lang.Object
,这两种情况下没有父类) - 类的直接接口的一个有序列表
线程私有区域
虚拟机栈
栈帧
动态链接
- 符号引用和直接引用在运行时进行解析和链接的过程,叫动态链接;
- 前提是每一个栈帧内部都要包含一个指向运行时常量池的引用,来支持动态链接的实现。
- 操作数栈:保存着Java虚拟机执行过程中的数据
局部变量表
- 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧分配多大的局部变量表空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
存放的信息
- 基本数据类型
- 对象引用
- returnAddress类型
方法返回地址
- 方法被调用的位置
- 方法退出的过程实际上就等同于把当前栈出栈
方法退出可能包含的操作
- 恢复上层方法的局部变量表和操作数栈
- 把返回值(如果有的话)压入调用者栈帧的操作数栈中
- 调整PC计数器的值以指向方法调用指令后面的一条指令
异常
- 线程请求的栈深度大于虚拟机所允许的深度 StackOverflowError
- JVM动态扩展时无法申请到足够的内存时 OutOfMemoryError
- 在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
- 本地方法栈:和虚拟机栈类似,区别是本地方法栈会使用到Native方法服务
程序计数器:
- 如果线程正在执行的是一个Java方法,则指明当前线程执行的代码字节码行数
- 此内存区域是唯一一个不会出现OutOfMemoryError情况的区域
- 如果正在执行的是Native方法,这个计数器值则为空(Undefined)
- 上述三个区域的生命周期和线程相同
直接内存
- 使用Native函数库直接分配堆外内存;
- 并不是JVM运行时数据区域的一部分,但是会被频繁使用;
- 避免再Java堆和Native堆来回复制数据,能够提高效率。
内存相关
内存分配
- 对象优先在Eden区分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。
- 大对象直接进入老年代:最典型的大对象是那种很长的字符串及数组。避免在Eden区和Survivor区之间的大量内存复制。
- 长期存活对象进入老年代:如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor中,并将对象年龄设置为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认15)时,就会被晋升到老年代;
- 空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看
HandlePromotionFailure
设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均大小,如果大于,将会尝试进行一次Minor GC,尽管这次Minor GC是有风险的,如果担保失败则会进行一次Full GC;如果小于或者HandlePromotionFailure
设置不允许冒险,那这时也要改为进行一次Full GC。
内存回收
Minor GC
- 特点:发生在新生代上,发生的比较频繁,执行速度较快;
触发条件:
- Eden区空间不足
- 空间分配担保
Full GC
- 特点:发生在老年代上,较少发生,执行速度较慢
触发条件:
- 调用
System.gc()
- 老年代区域空间不足
- 空间分配担保失败
- JDK1.7及以前的永久代(方法区)空间不足
- CMS GC处理浮动垃圾时,如果新生代空间不足,则采用空间分配担保机制,如果老年代空间不足,则触发Full GC
- 调用
内存溢出
- 程序在申请内存时,没有足够的内存空间
内存溢出的构造方式:
- 堆溢出:OutOfMemoryError:不断创建对象
栈溢出:
- StackOverflowError:增大本地变量表,例如不合理的递归
- OutOfMemoryError:不断建立线程
- 方法区和运行时常量池溢出:OutOfMemoryError:通过
String.intern()
方法不断向常量池中添加常量,例如String.valueOf(i++).intern()
- 本机内存直接溢出
内存泄漏
- 程序在申请内存后,无法释放已申请的内存空间
原因:
- 长生命周期的对象持有短生命周期对象的引用,例如将ArrayList设置为静态变量,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏;
- 连接被关闭,如数据库连接、网络连接和IO连接等,只有连接被关闭后,垃圾回收期才会回收对应的对象;
- 变量作用域不合理,例如:1.一个变量的定义的作用范围大于其使用范围 2.如果没有及时地把对象设置为null;
内部类持有外部类:
- Java的非静态内部类的这种创建方式,会隐式地持有外部类的引用,而且默认情况下这个引用是强引用,因此,如果内部类的生命周期长于外部类的生命周期,程序很容易就产生内存泄漏;
解决方法:
- 将内部类定义为static
- 用static的变量引用匿名内部类的实例
- 或将匿名内部类的实例化操作放到外部类的静态方法中
- Hash值改变:在集合中,如果修改了对象中的那些参与计算哈希值的字段,会导致无法从集合中单独删除当前对象,造成内存泄漏。
垃圾回收
对象是否存活
引用计数法
- 给对象添加一个引用计数器,当对象增加一个时计数器加1,引用失效时计数器减1.引用计数为0的对象可以被回收;
- 缺陷:循环引用会导致内存泄漏。
可达性分析算法
- 该算法是通过一系列的称为
GC Roots
的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径为引用链(Refence Chain),当一个对象到GC Roots
没有任何引用链相连(用图论的话来说,就是从GC Roots
到这个对象不可达时)时,则证明此对象是不可用的。 GC Roots
- 当前虚拟机栈中局部变量表中引用的对象;
- 当前本地方法栈中局部变量表中引用的对象;
- 方法区中静态属性引用的对象;
- 方法区中常量引用的对象。
- 该算法是通过一系列的称为
判断一个对象是否可回收的过程(两步):
- 找到
GC Roots
不可达的对象,如果没有重写finalize()
方法或者调用过finalize()
方法,则将该对象加入到F-Queue
中; - 再次进行标记,如果此时对象还未与
GC Roots
建立引用关系,则被回收。
- 找到
回收对象引用类型
- 强引用:垃圾回收器绝对不会回收它,当内存不足时宁愿抛出OOM错误,使得程序异常停止;
软引用:
- 垃圾回收器在内存充足的时候不会回收它,而在内存不足时会回收它;
- 软引用非常适合于创建缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。
- 弱引用:垃圾回收器在扫描到该对象时,无论内存释放充足与否,都会回收该对象的内存。ThreadLocal中的ThreadLocalMap中的key是弱引用。
虚引用:
- 如果一个对象只具有虚引用,那么它和没有任何引用一样,任何时候都可能被回收;
- 虚引用主要用来跟踪对象被垃圾回收器回收的活动。
垃圾收集算法
标记-清除算法
过程:
- 将需要回收的对象标记起来;
- 清除对象。
缺陷:
- 标记和清除的效率都不高;
- 会产生大量不连续的内存碎片。
复制算法:复制算法是将内存划分为两块大小相等的区域,每次使用时都只用其中一块区域,当发生垃圾回收时会将存活的对象全部复制到未使用的区域,然后对之前的区域进行全部回收。
- 新生代使用的复制算法
- 优点:简单高效,不会出现内存碎片问题
缺陷:
- 内存利用率低;
- 存活对象较多时效率明显会降低。
标记-整理算法:原理和标记清除算法类似,只是最后一步的清除改为将存活对象全部移动到一端,然后再将边界之外的内存全部回收。
- 老年代使用的是标记-整理算法
- 缺陷:需要移动大量对象,效率不高
分代回收算法
- 根据各个年代的特点选取不同的垃圾收集算法
- 新生代使用复制算法
- 老年代使用标记-整理或者标记-清除算法
垃圾回收器
Serial收集器
- 串行单线程收集器
- 优点:简单高效
- 是Client模式下的默认新生代收集器
ParNew收集器
- Serial收集器的多线程版本
- 是Server模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了Serial收集器,只有它能与CMS收集器配合工作。
Parallel Scavenge收集器
- 多线程收集器
- “吞吐量优先”收集器,更加关注系统的吞吐量
- 适合在后台运算而不需要太多交互的任务
Serial Old收集器
- Serial收集器的老年代版本
- 可用于Client模式下
用户Server模式下
- 在JDK1.5以及之前的版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用;
- 作为CMS收集器的备选预案,在并发收集发生Concurrent Mode Failure时使用。
ParNew Old收集器
- Parall Scavenge收集器的老年代版本
- 注重程序的吞吐量
CMS收集器
流程
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要停顿(STW);
- 并发标记:从GC Root开始对堆中对象进行可达性分析,找到存活对象,它在整个回收过程中耗时最长,不需要停顿;
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(STW)。;
- 并发清除:不需要停顿。
缺陷
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致CPU利用率不够高;
无法处理浮动垃圾,可能出现Concurrent Mode Failure
- 浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次GC时才能回收;
- 由于浮动垃圾的存在,因此需要预留出一部分的内存,意味着CMS收集不能像其它收集器那样等待老年代快慢的时候再回收;
- 由于预留的内存不够存放浮动垃圾,就会出现Concurrent Mode Failure,这是虚拟机将面临启用Serial Old来替代CMS。
- 会产生空间碎片:标记-清除算法会导致产生不连续的空间碎片。
G1收集器
- G1把堆划分为多个大小相等的独立区域(Region),新生代和老年代不再物理隔离;
流程:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时候,能可以在正确的Region中创建对象,此阶段需要停顿线程(STW),但耗时很短;
- 并发标记:从GC Root开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行;
- 最终标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中。着阶段需要停顿线程(STW),但是可并行执行;
- 筛选回收:首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
特点:
- 空间整合,不会产生内存碎片;
- 可预测的停顿。
参数设置:
- Xms
- Xmx
- Xmn
- Xss
- -XX:SurvivorRation
- -XX:NewRation
- -XX:+PrintGCDetails
- -XX:ParallelGCThreads
- -XX:+HeadDumpOnOutOfMemoryError
- -XX:+UseG1GC
- -XX:MaxGCPauseMillis
调优思路
确定释放有频繁Full GC现象?
如果Full GC频繁,那么考虑内存泄漏的情况,内存泄漏角度:
- 使用
jps -l
命令获取虚拟机的LVMID; - 使用
jstat -gc lvmid
命令获取虚拟机的执行状态,判断Full GC状态; - 使用
jmap -histo:live
分析当前堆中的存活对象数量; - 如果还不能定位到关键信息,使用
jmap -dump
打印出当前堆栈映像dump文件;jmap -dump:format=b,file=/usr/local/base/02.hprof 12942
- 使用MAT等工具分析dump文件,一般使用的参数是HIstogram或者Dominator Tree,分析出各个对象的内存占用率,并根据对象的引用情况,找到泄漏点。
- 使用
如果Full GC并不频繁,各个区域内存占用也很正常,那么考虑线程阻塞,死锁,死循环等情况,线程角度:
- 使用
jps -l
命令获取虚拟机LVMID; - 使用
jstack
分析各个线程的堆栈内存使用情况,如果说系统慢,那么要特别关注Blocked、Waiting on condition,如果说系统的cpu消耗高,那么肯定是线程执行有死循环,那么此时要关注下Runable状态; - 如果还不能定位到关键信息,使用
jmap -dump
打印出当前堆栈映像dump文件; - 使用MAT等工具分析dump文件,一般使用的参数是HIstogram或者Dominator Tree,分析出各个对象的内存占用率,并根据对象的引用情况,找到泄漏点。
- 使用
- 如果都不是,考虑堆外内存溢出,或者是外部命令等情况,
Runtime.getRuntime().exec()
。
其它知识
动态绑定
- 指的是在程序运行过程中,根据具体的实例对象才能具体确定是哪个方法;
- 编译阶段,根据引用本身的类型(Father)在方法表中查找匹配的方法,如果存在则编译通过;
- 运行阶段,根据实例变量的类型(Son)在方法表中查找匹配的方法,如果实例变量重写了方法,则调用重写的方法,否则调用父类的方法;
- 以
Father ft = new Son();ft.say();
为例; - 表中记录了这个类定义的方法的指针,每个表项指向一个具体的方法代码。如果这个类重写了父类中的某个方法,则对应表项指向新的代码实现处。从父类继承来的方法位于子类定义的方法的前面。
参数传递
- 值传递
- 引用传递
- Java在参数传递的时候,实际上是传递的当前引用的一个拷贝
- 如果参数是基本类型,传递的是基本类型的字面量值的拷贝;
- 如果参数是引用类型,传递的是该参数所引用的对象在堆内存中地址值的拷贝。