参考书籍:《深入理解Java虚拟机》
说说虚拟机调优的一些手段
命令行工具
jps(JVM Process Status):列出正在运行的虚拟机进程,并显示虚拟机执行主类名称以及这些进程的Benin虚拟机唯一ID
jstats(JVM Statistics Monitoring Tool):用于监视虚拟机各种运行状态信息的命令行工具,他可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据
jinfo(Java配置信息工具):实时查看和调整虚拟机各项参数
jmap(Memory map for java):用于生成堆转储快照
jhat(JVM Heap Analysis Tool):虚拟机堆转储快照分析工具
jstack(Stack Trace For Java):Java堆栈跟踪工具。用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合
可视化工具
- JConsole
- JHSDB
- VisualVM
- JMC
说说对象创建的过程
①类加载检查: 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。
②分配内存: 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种⽅式:选择以上两种⽅式中的哪⼀种,取决于 Java 堆内存是否规整。⽽ Java 堆内存是否规整,取决于 GC收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩")
③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
⑤执⾏ init ⽅法: 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始,<init> ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏<init> ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
第二部分:自动内存管理
运行时数据区域有哪些
- 程序计数器(线程独占)
- Java虚拟机栈(线程独占)
- 本地方法栈(线程独占)
- Java堆
- 方法区
程序计数器
- 可以被看作是当前线程所执行的字节码的行号指示器;是程序控制流的指示器
- 可以保证线程切换后能恢复到正确的执行位置
- 唯一一个没有规定任何OOM情况的区域
Java虚拟机栈
- 是线程私有的;生命周期与线程相同
- 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 局部变量表包括:基本数据类型(8种),对象引用,returnAddress类型
- 线程请求的栈深度大于虚拟机允许的深度,抛SOE异常。
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存才能会抛出OOME异常。
本地方法栈
- 为虚拟机使用到的本地(Native)方法服务
- 栈深度溢出或栈扩展失败抛SOE和OOM异常
Java堆
- 线程共享
- 几乎所有对象实例都在这里分配内存
- 是垃圾收集管理的内存区域
- 没有内存完成实例分配 || 堆无法再扩展,抛出OOME异常。
方法区(非堆)
- 线程共享
- 用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
- 方法区无法满足新的内存分配需求时,抛出OOME异常
运行时常量池
- 是方法区的一部分
- 用于存放编译器生成的各种字面量和符号引用
- 运行期也可以将新的常量放入池中
- 常量池无法申请到内存时会抛出OOME异常
直接内存
Full GC出现后,才能清除内存的废弃对象
直接内存( Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK 1.4中新加人了NIO ( New Input/Output) 类,引入了一种基干通道( Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
对象的创建过程
- 检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 虚拟机为新生对象分配内存。
- 虚拟机对对象进行必要的设置,例如这个对象是那个哪的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
《Java虚拟机规范中的两种异常》
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
运行时常量池是属于方法区的一部分
JDK7起,原本存放在永久代的字符串常量池被移至Java堆之中
JDK8后,永久代完全退出了历史舞台,元空间作为其替代者登场
垃圾回收的对象
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭这几个区域就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
如何判断对象已死(不可能被任何途径使用的对象)
策略1:引用计数算法。在对象中添加一个引用计数器,每有一个地方引用它,计数器值+1,当引用失效时,计数器值就-1。任何时刻计数器为零的对象就是不可能再被使用的。
弊端:难以解决对象相互循环引用的问题。
策略2:可达性分析算法。通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
哪些对象可以当做 GC Roots
在虚拟机栈(栈帧中的本地变量表)中引用的对象。
在本地方法栈中JNI(Native方法)引用的对象。
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
在方法区中常量引用的对象,譬如字符串常量池里的引用。
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器。
所有被同步锁(synchronized关键字)持有的对象。
反应Java虚拟机内部情况的JMXBean。JVMTI中注册的回调、本地代码缓存等。
还可以有其他对象“临时性”地加入
引用关系的类型(了解)
- 强引用:程序代码中普遍存在的引用赋值,如Object obj = new Object();。
- 软引用:还有用,但非必须。快要发生内存异常前,把这些对象列入回收范围进行第二次回收。
- 弱引用:被该引用关联的对象只能生存到下一次垃圾回收集发生为止。垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:不对其生存时间构成影响,是为了能在这个对象被收集器回收时收到一个系统通知。
两次标记过程
- 标记并筛选:对对象进行可达性分析后发现没有与GC Roots相连接的引用链,被第一次标记。随后筛选对象是否有必要执行finalize()方法,若没有覆盖finalize()方法或finalize()方法已经被虚拟机调用过,则虚拟机视其为“没有必要执行”。
- 入队、自我拯救、标记:如果这个对象被判定为有必要执行finalize()方法,对象就被放在一个队列中,等待一条Finalizer线程去执行他们的finalize()方法;稍后收集器对队列中的对象进行第二次小规模标记(此过程对象可在finalize()中拯救自己),它被移出“即将回收”集合。对象还没被逃脱,那就真的被回收了。
分代假说(理解)
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象越难消亡。
新生代老年代
在新生代中,每次垃圾收集都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
垃圾收集算法
- 标记-清除算法:标记并清除,或者标记并不清除
缺点:执行效率不稳定;内存碎片化 - 标记-复制算法:半区复制,当一块内存用完了,就将还存活着的对象复制- 到另外一块上面,然后把使用过的内存空间一次清理掉。(有其他优化设计)。
缺点:空间浪费大;较多对象存活时就要进行较多的复制操作,效率将降低。 - 标记-整理算法:标记并将所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存、
缺点:移动存活对象并更新所有引用这些对象的地方是负重极高的操作,而且必须全程暂停用户应用程序
Serial收集器
- 新生代收集器
- 复制算法
- 单线程,体现在进行垃圾收集时,必须暂停所有其他工作线程,直到收集结束
- 相对于其他收集器的单线程简单而高效
- 额外内存消耗最小、小型客户端应用比较好
ParNew收集器
- 新生代收集器
- 复制算法
- 是Serial收集器的多线程并行版本,同时使用多条线程进行垃圾收集,其余行为基本与Serial收集器完全一致
- 目前只有它和Serial收集器能与CMS配合工作
- 默认开启的收集线程数与处理器核心线程数量相同
Parallel Scavenge收集器
- 被称作吞吐量优先收集器
- 新生代收集器
- 复制算法
- 并行收集器的多线程收集器
- 目标是达到可控制的吞吐量,吞吐量=运行用户代码时间 / (运行用户代码时间+运行垃圾收集时间)
- 常被称作“吞吐量优先收集器”
Serial Old收集器
- 老年代收集器
- 使用标记-整理算法
- 单线程
- 主要意义是 1.供客户端模式下的HotSpot虚拟机使用,2.服务端和parallel scavenge配合使用;作为CMS失败的后背方案
Parallel Old收集器
- 老年代收集器
- 基于标记-整理算法实现
- Parallel Scavenge的老年代版本
- 支持多线程并发收集
- since JDK 6
- 缓解Parallel Scavenge的尴尬,让Parallel Scavenge有两个老婆
CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器
- 标记清除
运作过程有四个步骤:初始标记,并发标记,重新标记,并发清除
初始标记:标记一下 GC Roots能直接关联到的对象,速度很快
并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时长,用户线程可以与垃圾收集线程一起并发运行。
重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新),速度比初始标记慢一点点,远比并发标记快。
并发清除:清理删除掉标记阶段判断的已死对象;可以与用户线程同时并发。
- 并发收集、低停顿
- 4核以上处理器,处理器越多占运算资源越少;4核以下可能使用户程序执行速度大幅降低
- 无法处理“浮动垃圾”,有可能导致Full GC的参数。
- 老年代需预留空间供并发收集时的程序运作使用
- 碎片多,找不到连续大空间时需要Full GC(以及合并整理碎片)。
G1收集器
-
Garbage First
-
主要面向服务端应用
-
面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多。
-
基于Region的堆内存布局;能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元
-
Humongous区域存放大对象(对象大小超过半个Region)
-
比其他传统的垃圾收集器有着更高的内存占用负担
-
整体是基于“标记-整理”算法实现的,局部是基于“标记-复制”实现的。
四个运作过程
初始标记:仅仅标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。
并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。可与用户程序并发执行,扫描完成后,还要重新处理SATB记录下在并发时有引用变动的对象。
最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量SATB记录。
筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。
第三部分:虚拟机执行子系统
类加载的时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段。
这些过程按照此顺序按部就班地开始(解析有小例外)
加载
- 通过一个类的全限定类名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
验证
确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全,可大致分为以下4个阶段的检验动作。
- 文件格式验证:验证字节流是否符合Class文件格式的规范
- 元数据验证:对字节码描述的信息进行语义分析
- 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:对类自身以外的各类信息进行匹配性校验
准备
正式为类中定义的变量(静态变量)分配内存并设置类变量初始值的阶段
解析
是Java虚拟机将常量池内的符号引用替换为直接引用的过程
初始化
初始化阶段就是执行类构造器< clinit >()方法的过程
< clinit>()方法是编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并生成的
< clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成< clinit>()方法
类加载器
通过一个类的全限定名来获取描述该类的二进制字节流,实现这个动作的代码被称为“类加载器”。
类加载器的类型
-
启动类加载器:加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(文件名可识别)类库加载到虚拟机的内存中。
-
扩展类加载器:负责加载<JAVA_HOME>\lib\ext目录或者被java.ext.dirs系统变量所指定的路径中的所有的类库。
-
应用程序类加载器:负责加载用户类路径(Classpath)上所有的类库
-
自定义类加载器:用户自定义的类加载器
双亲委派模型
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。
如果没有双亲委派模型,系统出现同名类时,Java类型体系中最基础的行为就无从保证了(可以正常编译,无法被加载运行)
第五部分:高效并发
并发处理的广泛应用是Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压栈计算机运算能力的最有力武器。
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值)等都必须在工作内存中进行,而不能直接读写主内存中的数据
Java内存模型中的8种操作(是原子操作)
lock、unlock、read、load、use、assign、store、write
volatile关键字
保证变量对所有线程的可见性;禁止指令重排序优化
原子性保证
synchronized关键字、java.util.concurrent中的锁或原子类
可见性保证
synchronized关键字、final关键字、volatile关键字、
有序性保证
synchronized关键字、volatile关键字
先行发生原则(Happends-Before原则)
- 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样指时间上的先后。
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行于操作C的结论。
线程有哪些状态
- 新建
- 运行
- 无限期等待
- 限期等待
- 阻塞
- 结束
什么是线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
什么是同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或者是一些,当使用信号量的时候)线程使用
什么是互斥
互斥是实现同步的一种手段,临界区、互斥量、信号量都是常见的互斥实现方式
synchronized关键字使用注意
synchronized是Java语言中一个重量级操作
持有锁是一个重量级操作,因为阻塞或者唤醒一条线程,需要在操作系统中进行用户态和核心态的转换,而进行这种状态转换需要耗费很多处理器时间。
基于Lock接口,用户能够以非块结构来实现互斥同步
和synchronized相比增加的高级功能有
- 等待可中断:持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待,改为处理其他事情
- 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁。
- 锁绑定多个条件:一个RenntrantLock对象可以同时绑定多个Condition对象。
CAS的ABA问题
如果一个变量初次读取是A值,在CAS准备赋值的时候检查到它仍然为A值,但可能在初次读取和再次检查期间,这个变量被修改成为了B值,后来又改成了A值,那么CAS操作就会误认为它从来没有被改变过。
锁优化策略
自旋锁
如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自适应自旋锁
自适应意味着自旋的时间不再是固定的了,而是由前一次在同个锁上的自旋时间及锁的拥有者的状态来决定的。 如果在同一个锁对象上, 自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。
锁消除
如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
锁粗化
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
轻量级锁
轻量级锁中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。
- 在代码即将进入同步块的时候,如果此同步独享没有锁定(锁标志位为“01”状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
- 虚拟机使用CAS把对象的Mark Word更新为Lock Record的指针,如果成功,则该线程拥有该对象的锁了,锁标志位转为“00”;如果更新失败,虚拟机先检查对象的Mark Word是否指向当前线程的栈帧,如果是,则线程已经拥有了这个对象的锁,否则说明锁对象已经被其他线程抢占了,轻量级锁膨胀。
解锁过程:
- CAS把当前马克字和复制的马克字换回来,换成功了,说明同步过程顺利完成。
- 换失败了,说明其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
偏向锁
偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁时在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
启用偏向锁时
- 锁对象第一次被线程获取的时候,对象头的标志位设置为“01”,偏向模式设置为“1”。
- CAS把获取到这个锁的线程ID记录在对象的马克字之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对马克字的更新操作等。)
- 其他线程尝试获取这个锁,偏向模式结束。根据锁对象目前是否被处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志恢复到未锁定或轻量级锁定的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。