深入理解JVM
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解JVM相关的知识,希望对你有一定的参考价值。
一、内存管理
1、运行时的内存区域
线程私有:虚拟机栈、本地方法栈、程序计数器
线程共享:堆、方法区
2、各个内存区域可能抛出的异常
-
栈
1、当单线程时,栈的深度太大,会发生StackOverflowError,比如无穷的递归调用。
2、当多线程时,若不停地创建线程,则会导致OutOfMemoryError,因为除去堆和方法区之外,剩下的栈总空间是有限的,不停创建线程则会不停申请栈空间,最终会导致内存溢出。
-
堆
当不停地创建(new)对象时,会导致OutOfMemoryError
-
方法区
运行时产生大量的类,去填满方法区,比如用CGLib去无穷生成类。
-
直接内存
使用Unsafe分配本机内存时,可能导致OutOfMemoryError。
3、各个内存区域容量设置的参数
-
-Xss2M:设置栈的容量为2M
-
-Xms10M:设置堆的初始容量为10M
-
-Xmx10M:设置对的最大容量为10M
-
-XX:PermSize=10M:设置方法区的初始容量为10M
-
-XX:MaxPermSize=10M:设置方法区的最大容量为10M
-
-XX:MaxDirectMemorySize=10M:设置直接内存的最大容量为10M
4、对象的创建
-
如何在堆中分配内存
根据内存是否规整,即GC收集器是否带有压缩整理功能,分为指针碰撞和空闲列表两种。
-
如何处理内存分配冲突
1、CAS+失败重试;2、TLAB,即本地线程分配缓冲。
-
对象在内存中的布局
对象头(哈希码、GC分代年龄、所状态标志等)、实例数据、对象填充
-
如何访问对象
1、通过句柄(栈上的指针指向句柄,句柄中分别有指向对象的指针,和指向类信息的指针)
2、直接指针(栈上的指针直接指向堆中的对象,对象中头部有一个类型指针,指向类型信息)
5、对象存活判定
即如何判断一个对象所占用的内存是否该回收?
有两种方法:1、引用计数法;2、可达性分析法。
-
引用计数法
该方法容易出现循环引用的问题,JVM并未采用。
-
可达性分析法
判断是否能从GCRoots中找到一条到达该对象的路径。
GCRoots包括:栈中变量引用的对象、方法区中静态属性(static)引用的对象、方法区中常量(final)引用的对象。
6、垃圾回收
-
引用的种类
1、强引用:通常new出来的对象的引用都是强引用。
2、软引用(SoftReference):如果某次回收完之后,还是可能发生内存溢出,则进行第二次回收,在第二次回收时会回收软引用,若这次回收后仍是内存不够,这时候才发生内存溢出。
3、弱引用(WeakReference):其指向的对象只能生存到下一次垃圾收集之前,无论当前内存是否足够,都会回收弱引用指向的对象。
4、虚引用(PhantomReference):设置虚引用的目的可能是为了,在该对象被回收前能够得到一个系统通知。
-
finalize方法
若某类覆盖了该方法,则其对象再被回收前会调用此方法(仅限于第一次回收该对象时)。
-
方法区的回收
废弃的常量:当前系统中没有一个对象引用此常量。
无用的类:堆中不存在该类的任何实例,该类的类加载器已被回收,该类的Class对象没有在任何位置被引用。
-
垃圾收集算法
1、JVM整体上是采用“分代收集算法”。
根据对象的存活周期的不同将Java堆分为新生代和老年代。
新生代又分为Eden区,Survivor From区,Survivor To区,其大小比例默认为8:1:1。
Java的方法区被定义为永久代
2、对于新生代,一般采用“复制算法”。因为新生代的存活时间相对较短,复制的时候不会复制太多对象,所以整体效率不至于太低。
当从Eden和Survivor From区向Survivor To区复制时,若Suvivor To区的空间不够,则需要依赖老年代进行“分配担保”。
3、对于老年代,一般采用“标记-清除”(容易产生内存碎片)或“标记-整理”算法。因为老年代的对象存活率较高,若仍是采用复制操作,则需要复制的对象太多,效率会很低。
4、Stop The World
在判断对象是否应该被回收时,是通过GCRoots来判断的。
当枚举GCRoots时,不可以出现在分析过程中,对象的引用关系还在不断发生变化,所以这时候必须停顿所有线程,这种现象称为“Stop The World”。
5、安全点和安全区域
安全区域是指:在这段代码片段内,引用关系不会发生变化。
6、内存分配和回收策略
对象优先在Eden区分配,若内存不够则进行一次Minor GC,将Eden区的活跃对象复制到Survivor To区。
若Survivor To区大小足够,则将其中的存活对象的GC年龄加1,并判断是否应该晋升到老年代区。
若Survivor To区大小不够,则进行分配担保,将对象复制到老年代。
若此时老年代内存大小不够,则进行一次Full GC。
-
垃圾收集器
-
Serial:新生代收集器,单线程收集器,采用复制算法。
-
ParNew:新生代收集器,多线程收集器,采用复制算法。
-
Parallel Scavenge:新生代收集器,多线程收集器,侧重于提高程序运行的吞吐量。
-
Serial Old:老年代收集器,单线程收集器,采用标记-整理算法。
-
Parallel Old:老年代收集器,多线程收集器,采用标记-整理算法。
由于多线程的老年代收集器可以充分利用服务器多CPU的处理能力,所以常用Parallel Scavenge/Parallel Old组合,亦提高了吞吐量。
-
CMS(Concurrent Mark Sweep)
老年代收集器
初始标记-->并发标记-->重新标记-->并发清除
-
G1收集器
初始标记-->并发标记-->最终标记-->筛选回收
二、执行子系统
(一)、类文件结构
1、平台无关性和语言无关性
平台无关性:通过Java虚拟机,Java代码可以运行在不同的操作系统上。
语言无关性:不同的语言通过编译成字节码,均可以运行在JVM上。
2、Class文件结构
(1)、 两种数据类型:无符号数,表。
(2)、魔数:“CAFEBABE”
(3)、次版本号,主版本号
(4)、常量池:常量个数,常量项(类型tag+内容)。包括字面量和符号引用。
(5)、访问标志:是否 public,final,super,interface,abstract,synthetic,annotation,enum
(6)、类索引,父类索引,接口索引
(7)、字段表
(8)、方法表
(9)、属性表
3、字节码指令
(1)、字节码中的数据类型:byte、short、int、long、float、double、char、reference
(2)、加载和存储指令:将数据加载到操作数栈,将数据存储到局部变量表
(3)、运算指令:加减乘除、取余、取反、位移、位运算(与、或、异或)、局部变量自增、比较
(4)、类型转换
(5)、对象创建和访问
(6)、操作数栈相关指令
(7)、控制转移指令:条件分支等
(8)、方法调用和返回:
invokevirtual(实例方法)
invokeinterface(接口方法)
invokespecial(构造方法,私有方法,父类方法)
invokestatic(静态方法)
(9)、同步指令:monitorenter、monitorexit。通过管程实现,用于支持synchronized关键字。
(二)、类加载机制
1、类在什么时候会加载
(1)、new(使用new实例化对象的时候),getstatic(读取一个类的静态字段,不包括final的),putstatic(设置一个类的静态字段,不包括final的),invokestatic(调用一个类的静态方法时)。
(2)、通过java.lang.reflect包的方法,对类进行反射调用的时候,若类未初始化,则会对其进行初始化。
(3)、当初始化一个类时,若其父类还未初始化,则先初始化其父类。
(4)、虚拟机启动时,会先初始化包含main方法的那个类,即主类。
(5)、被动引用不会导致类加载,比如:通过子类引用父类的静态字段,不会导致子类初始化;定义某类的数组,则该类不会初始化;引用某类的静态常量,则该类不会初始化。
2、类加载的过程
(1)、加载
通过类的全限定类名获取二进制流;将字节流转换为方法区内的运行时数据结构;在内存中生成一个代表该类的Class对象,作为方法区里这个类的各种访问数据的入口。
(2)、验证
校验Class文件中的信息是否复合JVM的要求。
文件格式验证(基于二进制字节流,校验主次版本号是否支持等);
元数据验证(是否继承的final类,是否实现了接口的所有方法等);
字节码验证(通过数据流和控制流分析,验证程序语义,比如只能父类引用指向子类对象,子类引用不能指向父类对象);
符号引用验证(当JVM将符号引用转换为直接引用时,会检查是否能根据名称找到相应的类,方法,字段等);
验证阶段其实不是必须的,如果该字节码被反复验证过,其实可以关闭验证。
(3)、准备
为静态变量设置初始值(int为0,reference为null等);
为常量设置初始值。
(4)、解析
将常量池里的符号引用解析为直接引用(即指向内存中某个区域的指针)。
解析的符号引用有:类或接口、字段、方法、接口方法等。
(5)、初始化
若父类没有加载,则先加载父类;
然后为静态变量设置初始值,执行静态代码块等;
3、类加载器
(1)、每个类,都要由“加载它的类加载器”和“这个类本身”一块确定该类在虚拟机里的唯一性。
(2)、类加载器种类
启动类加载器:加载<JAVA_HOME>/lib目录中的类;
扩展类加载器:加载<JAVA_HOME>/lib/ext目录中的类;
应用程序类加载器:加载CLASSPATH中的类,如果应用程序没有自定义过自己的类加载器,这个便是程序中默认的类加载器。
(3)、双亲委派模型
当一个类加载器加载类的时候,首先不会亲自加载这个类,而是会把这个请求委派给父加载器去加载,如此递归下去,所有的请求最终会传到顶层的引启动类加载器。只有当父加载器无法加载该类时,才会让子类去尝试加载。
这样做可以保证,同一个类在虚拟机中不会被不同的类加载器加载很多次。
(三)、字节码执行引擎
1、运行时栈帧结构
程序执行时,内存中的栈,里面是一个一个的栈帧。每一个方法的调用及其执行,都对应着一个栈帧。
2、方法调用
(1)、方法调用指令
invokestatic:调用静态方法
invokespecial:调用实例构造器、私有方法、父类方法
invokesvirtual:调用所有的虚方法(非static非final方法)
invokeinterface:调用接口方法
(2)、分派
静态分派:对应于方法参数上的重载。编译器在重载时,是通过参数的静态类型,而不是实际类型作为判断依据的。
动态分派:对应于多态。当invokespecial指令执行时,第一步就是在运行期确定接受者的实际类型。
三、代码优化
-
编译期优化
(1)、编译过程
词法分析,语法分析-->
填充符号表-->
处理注解-->
语义分析(标注检查、数据及控制流分析、解语法糖)-->
字节码生成。
(2)、Java的语法糖
泛型与类型擦除
自动装箱、拆箱
foreach循环
变长参数
注意:1、JVM在字节码里,用Signature属性存储了方法在字节码层面的方法签名,通过这项元数据,可以通过反射获取类的泛型信息。2、包装类的“==”运算,在不遇到算数运算时不会自动拆箱,这时候比较的是引用是否相等。
-
运行期优化
(1)JIT编译器(Just In Time)
Java程序最初是通过解释执行的,当虚拟机发现“某个方法或某段代码块
”运行特别频繁时,会把这些代码认定为“热点代码(Hot Spot Code)”。为了提高热点代码的执行效率,在运行时,会把这些代码编译为与本地平台相关的机器码,并进行各种优化。完成这个任务的编译器叫做即时编译器。
(2)、解释器与编译器并存
-
1、编译对象
被多次调用的方法:将整个方法作为编译对象。
被多次执行的循环体:依然将整个方法作为编译对象,进行栈上替换(On Stack Replacement),即替换栈帧。
2、热点探测的方式
基于采样:周期性的检查栈顶,若某方法经常出现在栈顶,则其是热点方法。
基于计数器:统计方法执行次数,到了一定的阈值,则其是热点方法。
3、两种计数器
JVM的热点探测是基于计数器的,有两种计数器:方法调用计数器和回边计数器。
方法调用计数器:即统计方法执行次数。
回边计数器:循环体中代码执行的次数。“回边”,即在字节码中遇到控制流向后跳转的指令。
4、编译优化技术
公共子表达式消除、数组边界检查消除、方法内联、逃逸分析等。
四、并发
1、JVM内存模型和线程
(1)、JVM内存模型
1、JVM内存模型主要是定义程序中变量的访问规则,即在虚拟机中将变量存储到内存,和从内存中取出变量这样的细节。这些变量指的是实例字段、静态字段等,不包括局部变量(因为局部变量是线程私有的,不被共享,不存在竞争问题)。
2、工作内存中保存了该线程使用到的变量的在主存中的拷贝。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行,不能直接读写主存中的变量。
3、Java内存模型中的工作内存只是个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其它的硬件和编译器优化。
4、内存间的交互操作
lock、unlock
read、load、use、assign、store、write
5、volatile变量
6、原子性、可见性、有序性
7、happens-before原则
(2)、线程
1、线程的实现方式
三种方式:1:1、1:N、N:M。
Java是通过将线程映射到操作系统的线程上去实现的。
2、线程的调度方式
两种方式:协同式线程调度、抢占式线程调度。
Java中使用的是抢占式线程调度。
3、线程的状态转换
1、线程安全和锁优化
-
线程安全
(1)、共享数据的种类:
1、不可变:不可变对象是值“对象中带有状态的变量都声明为final”。
2、绝对线程安全
3、相对线程安全:Vector、Hashtable等
4、线程兼容:ArrayList、HashMap
5、线程对立
(2)、线程安全的实现方法
1、互斥同步(Mutual Exclution & Synchronization)
同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(一些)线程使用。
互斥是实现同步的一种手段,临界区、互斥量、信号量都是互斥的实现方式。
互斥是因,同步是果;互斥是方法,同步是目的。
互斥同步又称为“阻塞同步”,属于一种悲观的并发策略。
2、非阻塞同步
非阻塞同步是一种基于“冲突检测”的乐观并发策略;
即先进行操作,如果“没有其它线程争用共享数据”,那操作就成功了。如果共享数据有争用,产生了冲突,那就再采取其它的补偿措施(比如不断重试,直到成功为止)。
因为这种策略不需要将线程挂起,所以成为非阻塞同步。
“操作和冲突检测”这两个步骤,需要具备原子性,这可以通过硬件的CAS来实现。
CAS指令需要三个操作数:内存位置,旧的预期值,新值。当且仅当“内存位置的值”符合“旧的预期值”时,处理器用“新值”更新“内存位置的值”,否则就不更新。最终无论是否更新,均会返回“旧值”。
3、不须同步
可重入代码:不依赖堆上的数据和共用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法。如果一个方法,它的返回结果可预测,相同的输入,均能返回相同的输出,它便是可重入的。
线程本地存储:即ThreadLocal,以当前线程哈希码为键,某变量位置的一个键值对。
-
锁优化
1、自旋锁
因为当线程阻塞,或从阻塞中恢复时,挂起线程和恢复线程的操作都需要转入到内核态去完成,这给系统的性能带来了很大压力。
如果某个锁被占用的时间很短,这时候可以让“后面那个请求锁的线程”稍微等一下,不放弃CPU的执行时间,执行一个忙循环,直到获得锁。
2、锁消除
将一些代码上进行了同步,但实际不会存在共享数据竞争的锁进行消除。
3、锁粗化
如果对一个对象反复加锁和解锁,甚至这个对象在循环体内,则可以把锁加到循环体外,这样可以消除反复加解锁所带来的损耗。
4、轻量级锁
5、偏向锁
以上是关于深入理解JVM的主要内容,如果未能解决你的问题,请参考以下文章