一、类加载机制
类加载就是虚拟机把Class文件加载到内存,并对数据进行校验,解析和初始化,形成可以虚拟机直接使用的Java类型,即java.lang.Class。类加载不包括使用和卸载阶段。
1、加载
- (1)通过一个类的全限定名获取定义此类的二进制字节流
- (2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- (3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口
Class对象封装了类在方法区内的数据结构,并且提供了访问方法区内的数据结构的接口。
在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
2、链接
- (1)验证:保证被加载类的正确性
包括文件格式验证、元数据验证、 字节码验证、符号引用验证。 - (2)准备:为类的静态变量分配内存,并将其初始化为默认值(含 static 修饰的变量不含实例变量)
比如代码为public static int age = 10,准备阶段完成之后将age的值初始化为0。 - (3)解析:把常量池内的符号引用转换为直接引用
符号引用就是一组符号来描述目标,可以是任何字面量。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
3、初始化
初始化就是为类的静态变量,静态代码块执行初始化的操作。
比如代码为public static int age = 10,初始化阶段完成之后将age的值初始化为10。
类装载器ClassLoader
在加载阶段,其中第(1)步:通过类的全限定名获取其定义的二进制字节流,需要借助类装载
器完成,顾名思义,就是用来装载Class文件的。
- (1)BootStrap ClassLoader 启动类加载器
负责加载$JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由C++实现,不是ClassLoader子类。 - (2)Extension ClassLoader 扩展类加载器
负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中 jre/lib/*.jar 或 -Djava.ext.dirs指定目录下的jar包。 - (3)App ClassLoader 应用类加载器
负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和 jar包。 - (4)Custom ClassLoader 自定义加载器
通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。
加载原则
检查某个类是否已经加载:顺序是自底向上。
加载的顺序:加载的顺序是自顶向下。
双亲委派模型
如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
二、JVM内存结构
1、运行时数据区
1、方法区
- (1)线程共享,在虚拟机启动时创建
- (2)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- (3)当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
2、堆
- (1)线程共享,在虚拟机启动时创建,堆是Java虚拟机所管理内存中最大的一块
- (2)Java对象实例以及数组都在堆上分配
- (3)内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)
- (4)如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出OutOfMemoryError异常
3、虚拟机栈
- (1)线程私有,生命周期和线程一致
- (2)每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息
- (3)当线程请求的栈深度大于虚拟机所允许的深度时,会发生StackOverflowError异常;如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存时,会发生OutOfMemoryError异常
4、本地方法栈
区别于 Java 虚拟机栈的是,Java 虚拟机栈执行的是Java方法,而本地方法栈执行的是Native方法。也会有 StackOverflowError 和 OutOfMemoryError 异常。
5、程序计数器
- (1)由于Java虚拟机的多线程是通过线程轮流切换,并分配处理器执行时间的方式来实现的,在任意时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程需要有一个独立的程序计数器(线程私有)。程序计数器占用的内存空间很小,此区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
- (2)如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址
- (3)如果正在执行的是Native方法,则这个计数器为空
2、Java对象的内存布局
一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充。具体存储内容如下图
对象的访问定位:使用对象时,通过栈上的 reference 数据来操作堆上的具体对象。
- (1)通过句柄访问:Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。
- (2)使用直接指针访问:reference 中直接存储对象地址。
比较:使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference 自身不需要修改。使用直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好。
Java堆的内存结构
堆区分为两大块,一个是Old区,一个是Young区。
Young区分为两大块,一个是Survivor区(S0+S1),一块是Eden区。 Eden:S0:S1=8:1:1
S0和S1一样大,也可以叫From和To
对象创建所在区域
Eden区详解
一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。如果Eden区内存空间不足,会发生一次Minor GC。经过GC之后,部分不活跃的对象就会被清理,存活的对象就会被赋值到Survivor区,然后把Eden区清空。
Survivor区详解
Survivor区分为两块S0和S1,也可以叫做From和To。在同一个时间点上,S0和S1只能有一个区有数据,另外一个是空的。
比如Eden区和From区有对象,当发生GC时,Eden区存活的对象会被复制到To区。From区中还存活的对象的年龄会+1,当年龄到达设置好的阈值时,会被移动到Old区,没有达到阈值的对象会被复制到To区。此时Eden区和From区已经被清空,这时候From和To交换角色,之前的From变成了To,之前的To变成了From。
Minor GC会一直重复这样的过程,知道To区被填满,然后会将所有对象复制到老年代中。
Old区详解
从上面的分析可以看出,一般Old区都是年龄比较大的对象,或者相对超过了某个阈值的对象。
在Old区也会有GC的操作,Old区的GC我们称作为Major GC。
如何理解Minor/Major/Full GC
Minor GC:新生代
Major GC:老年代
Full GC:新生代+老年代
为什么需要Survivor区?只有Eden不行吗?
如果没有Survivor,Eden区每进行一次Minor GC,并且没有年龄限制的条件下,存活的对象就会被送到老年代。这样一来,老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。
所以Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
为什么需要两个Survivor区?
最大的好处就是解决了碎片化。
一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
逃逸分析
逃逸分析就是JIT会分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存。
逃逸分析的 JVM 参数如下:
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
使用逃逸分析,编译器可以对代码做如下优化:
- (1)锁消除:当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁。
- (2)标量替换:对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。
- (3)栈上分配:当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中。
JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。
所以,对象和数组并不是都在堆上分配内存的。
TLAB
TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。
TLAB本身占用Eden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB空间。由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行分配,总是会直接分配在老年代。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。
虚拟机采用两种方式来保证线程安全:
- (1)CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁,而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- (2)TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。
对象内存分配的两种方法
为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
- (1)指针碰撞(Serial、ParNew等带Compact过程的收集器)
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。 - (2)空闲列表(CMS这种基于Mark-Sweep算法的收集器)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
三、垃圾回收(Garbage Collect)
如何确定一个对象是垃圾?
1、引用计数法
对于某个对象而言,只要应用程序中持有该对象的引用,就说明该对象不是垃圾,如果一个对象没有任何指针对其引用,它就是垃圾。
弊端:如果AB相互持有引用,导致永远不能被回收。
2、可达性分析
通过GC Root的对象,开始向下寻找,看某个对象是否可达。
可作为 GC Roots 的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象
引用
前面的两种方式判断存活时都与‘引用’有关,在Java中共有4种引用
- (1)强引用
类似于 Object obj = new Object(); 创建的,只要强引用在就不回收。 - (2)软引用
SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。 - (3)弱引用
WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。 - (4)虚引用
PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
回收时机
即使在可达性分析算法中不可达的对象,也不是立马回收的。一个对象的真正死亡至少要经历两次标记过程:果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象竟会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可。
finalize() 方法只会被系统自动调用一次。
垃圾收集算法
1、标记-清除(Mark-Sweep)
- 标记:找出内存中需要回收的对象,并且把它们标记出来
- 清除:清除掉被标记需要回收的对象,释放出对应的内存空间
缺点:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程
序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2、复制(Copying)
将内存划分为两块相等的区域,每次只使用其中一块,当其中一块内存使用完了,就将还存活的对象复制到另外一块上面,然后把已经使用过的内存空间一次清除掉。
缺点:缺点: 空间利用率降低。
3、标记-整理(Mark-Compact)
标记过程仍然与"标记-清除"算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
在堆中使用的是分代收集算法:
Young区:复制算法(对象在被分配之后,可能生命周期比较短,Young区复制效率比较高)
Old区:标记清除或标记整理(Old区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)
垃圾收集器
1、Serial收集器
它是一种单线程收集器,不仅仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是其在进行垃圾收集的时候需要暂停其他线程。
- 算法:复制算法
- 适用范围:新生代
- 优点:简单高效,拥有很高的单线程收集效率
- 缺点:收集过程需要暂停所有线程
- 应用:Client模式下的默认新生代收集器
2、ParNew收集器
可以把这个收集器理解为Serial收集器的多线程版本。
- 算法:复制算法
- 适用范围:新生代
- 优点:在多CPU时,比Serial效率高。
- 缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。
- 应用:运行在Server模式下的虚拟机中首选的新生代收集器
3、Parallel Scavenge收集器
Parallel Scavenge收集器是和ParNew收集器相似,是一个新生代收集器,采用复制算法,是一个并行的多线程收集器。但是Parallel Scanvenge更关注系统的吞吐量。
吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)
4、Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,不同的是采用"标记-整理算法",运行过程和Serial收集器一样。
5、Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和"标记-整理算法"进行垃圾回收。吞吐量优先
6、CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,是一个老年代收集器,采用"标记-清除算法"。整个过程分为4步
- (1)初始标记:标记GC Roots能关联到的对象 Stop The World
- (2)并发标记:进行GC Roots Tracing,就是从GC Roots开始找到它能引用的所有其它对象
- (3)重新标记:修改并发标记因用户程序变动的内容 Stop The World
- (4)并发清除:
在整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,因此,从总体上看,CMS收集器的内存回收过程是与用户线程一起并发执行的
7、G1收集器
使用G1收集器时,Java堆的内存布局与就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
工作流程分为4步:
- (1)初始标记:标记GC Roots能关联到的对象 Stop The World
- (2)并发标记:进行GC Roots Tracing,就是从GC Roots开始找到它能引用的所有其它对象
- (3)最终标记:修改并发标记因用户程序变动的内容 Stop The World
- (4)筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划
垃圾收集器分类
- (1)串行收集器:Serial和Serial Old
只能有一个垃圾回收线程执行,用户线程暂停。适用于内存比较小的嵌入式设备。 - (2)并行收集器[吞吐量优先]:Parallel Scanvenge、Parallel Old
多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。适用于科学计算、后台处理等弱交互场景。 - (3)并发收集器[停顿时间优先]:CMS、G1
用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行。适用于相对时间有要求的场景,比如Web 。
停顿时间:垃圾收集器 进行 垃圾回收终端应用执行响应的时间
吞吐量:运行用户代码时间/(运行用户代码时间+垃圾收集时间)
垃圾回收的时机
(1)当Eden区或者S区不够用了
(2)老年代空间不够用了
(3)方法区空间不够用了
(4)System.gc()
System.gc()只是通知要回收,什么时候回收由JVM决定。但是不建议手动调用该方法,因为消耗的资源比较大。
内存泄漏与内存溢出的区别
- 内存泄漏是指不再使用的对象无法得到及时的回收,持续占用内存空间,从而造成内存空间的浪费。
- 内存溢出:要求分配的内存超出了系统能给你的,系统不能满足需求
内存泄漏很容易导致内存溢出,但内存溢出不一定是内存泄漏导致的。
四、JVM性能优化
常用命令
(1)jps:查看java进程
(2)jinfo:实时查看和调整JVM配置参数
(3)jstat:查看虚拟机性能统计信息
(4)jstack:查看线程堆栈信息
(5)jmap:生成堆转储快照
常用工具
(1)JConsole
(2)JVisual VM
(3)Arthas
(4)MAT
GC日志文件分析工具
(1)gceasy
(2)GCViewer