一篇文章搞定Java虚拟机
JVM是Java虚拟机,是保证Java程序可以跨平台运行的关键。
Java源代码编译成二进制字节码(JVM指令)--》》JVM指令会通过解释器翻译成机器码。
内存结构
线程私有
- 程序计数器
- 记住下一条JVM指令的执行地址
- 特点
- 线程私有的
- 不会存在OOM(内存溢出)
- 虚拟机栈
- 每个线程运行时需要的内存空间
- 因此局部变量是线程安全的,但是静态变量是共享的,因此需要考虑同步问题。
- 另外区分局部变量是否线程安全应该考虑的是其他的线程是否可以拿到,比如局部变量是一个对象并且作为参数或者返回值时(局部变量没有逃离方法的作用范围),就会出现线程不安全
- 上述说的局部变量如果是一个基本数据类型,就不会出现线程不安全
- 栈中存在多个栈帧(每个方法运行时需要的内存,参数,局部变量,返回地址)
- 每个线程只能有一个活动栈帧,正在执行的方法
- 栈的内存不是越大越好,划分很大会增加递归深度,但是会减小线程数量。
- 栈内存溢出(StackOverFlow)
- 栈帧过多(递归深度过深)
- 栈帧过大(代码中封装过多局部变量)
- 查看线程Jstack,进一步定位到源码行数
- 每个线程运行时需要的内存空间
- 本地方法栈
- 本地方法(不是由Java代码编写的,使用本地方法(其他语言编写native)的接口来实现)
- 为本地方法提供一个内存空间
线程共享🌟🌟🌟🌟
-
堆(Heap)
- 堆内存溢出(OutOfMemory)-Xmx设置堆内存
- 堆内存诊断工具
- jps查看进程
- jmap查看内存占用
Jmap -heap PID - Jconsole 查看线程,内存占用,类加载情况(图像化界面)
- jvisualvm(更好用)
-
方法区(Mmethod Area)
-
虚拟机启动时创建
-
版本迭代
- 永久代---方法区中存放运行时常量池,类,和类加载器,并且存放在JVM内存中,堆一起
- 元空间---Java1.8之后的实现,通过将方法区设置在直接内存(操作系统的内存)中,并将运行时常量池添加在Heap中
-
内存溢出

- 元空间溢出---很难溢出(用的是物理内存,但是可以设置虚拟参数)
- 永久代内存溢出(最大内存)
- 使用动态加载时会出现出现多个类导致内存溢出
-
常量池
- 二进制字节码包含类基本信息,常量池(存放的是常量值和对象引用),类方法定义
- 类似于编译原理中的符号表(存放中间变量)
- 运行时常量池--当类被加载,常量信息就会放入运行常量池中,并将对象中的符号地址变成真实引用的地址
- 常量池与StringTable的关系
- 当类被加载后运行时常量池中的字符串并未被创建成对象,当运行到具体的字节码时,才会被变成对象并放入StringTable中(把字符串当作key去比较,如果出现一致的key就不放入,否则创建对象并放入)
- StringTable是一个HashTable不能扩容
- 字符串拼接
- 两个对象进行拼接--创建StringBuilder对象,传入参数后,并调用其ToString方法(将拼接的参数创建成一个新的String对象);
- 两个常量进行拼接,在编译期间直接进行拼接(因为不会发生变化)。则直接将其进行连接后查找对象。
- 字符串的延迟加载(并不是直接放入StringTable中,而是通过运行时才进行加载)
- StringTable是用来避免重复创建对象
- 还可以使用intern方法,将常量池中的String字符串对象放入StringTable
- 1.8中将字符串对象尝试放入StringTable,如果有则并不会放入,如果没有则放入,不管有没有都会返回
- 1.6中将这个字符串对象尝试放入StringTable,如果有则不放入,如果没有,则会将该对象复制一份,放入String Table
- StringTable的位置(因为垃圾回收机制)
- 永久代的GC触发机制--FullGC的时候才会进行回收,触发的时机会有点晚
- 堆中的GC触发机制--minumGC就会进行回收
- StringTable会进行垃圾回收,当其中的内存占用过高,就会调用GC来清除那些没有引用的对象。
- StringTable的性能调优
- 设置桶(HashTable中的数组)个数
- 考虑字符串对象是否入池
- 常量池中一般存放的对象都是字面值,也就是常量
- 如果程序中出现了拼接字符串的操作,最后拼接成的字符串对象是不会出现在常量池中的。
- 如果有拼接后的字符串直接出现,不会使用常量池中已经有的对象,而是需要在常量池中新建一个对象。也就是并没有进入String Table里面。
- 这个时候可以选择使用intern方法将常量池中的对象进入StrinngTable中,那么下次再遇到同样的字符串,就会直接使用StringTable里面的地址,而不会再次创建。
- StringTable就是为了保证常量池中不会有重复的数据被创建占据内存。
-
直接内存
-
分配回收成本高,读写性能高,常见于NIO操作,用于数据缓冲区
-
不受JVM内存回收管理
原因:用户态的Java程序会调用内核态的系统内存方法去读写磁盘文件,系统本身会建立一个缓冲区,Java同样也会建立一个缓冲区,因此会做一些重复的复制操作使得性能不佳。而使用直接内存的时候,将操作系统的缓冲区可以直接给Java访问。
-
直接内存溢出
直接内存释放原理:底层是通过使用Unsafe对象进行分配和释放内存,而实际上调用则是通过虚引用的方式---当Java对象被垃圾回收器回收后,会通过执行Cleaner的create创建一个ReferenceHandler守护线程来执行直接内存的释放
-
禁用显示回收对直接内存的影响:
会出现系统自己调用的垃圾回收器不能生效,而只有等到垃圾回收自动被调用才会将直接内存进行释放。
但是也可以通过反射获取到Unsafe对象,进行手动释放直接内存
垃圾回收机制
如何判断对象是垃圾
-
引用计数(如果出现循环引用,会出现内存泄漏)
-
可达性分析(根对象不能够访问的对象就被当成垃圾进行回收 GC ROOTs)通过Jmap dump内存文件,并通过MAT进行分析
-
五种引用
-
强引用(直接被GCROOTS引用,垃圾回收的时候不会被回收)
软弱引用可以通过引用队列(软弱引用的发起者也是一个对象,在其引用对象被回收之后,会将其放入引用队列中,在合适的时机进行遍历释放)
-
软引用(发生垃圾回收的时候,如果内存不足,会将其回收,在发生垃圾回收后,没必要去保留对象,可以配合和引用队列进行删除)
-
弱引用(发生垃圾回收就会被回收,配合引用队列与软引用类似)
必须配合引用队列
-
虚引用---(在发生垃圾回收时,其分配的直接内存并不会被释放,而是将虚引用的发起对象放入引用队列中,然后会有一个ReferenceHandler守护线程定时进行检查队列中的对象,如果有就会执行unsafe的内存释放方法)
-
终结器引用---(在发生垃圾回收时,先将终结器引用的发起者对象放入引用队列,并用一个优先级很低的finalizeHandler线程定期检查其中的对象,并执行其finalize方法,然后在下一次执行垃圾回收的时候将内存回收),工作效率低
-
回收算法
- 标记(将是垃圾的对象进行标记)---清除(把垃圾对象的起始地址放入一个空闲链表,下次分配的时候就可以当作空闲内存进行分配)缺陷---容易产生内存碎片
- 标记(同上)---整理(在清理的同时,会把可用的内存往前移动)缺陷--移动的时候需要修改地址,速度较慢
- 复制(From-To,做一次标记,将From中不是垃圾的对象复制到To区中(复制的过程默认整理好碎片),最后交换From和To)缺点--占用双倍内存
- 分代回收
- 对于不同类型的对象,将生命周期比较短的对象放进新生代中,将生命周期较长的对象放进老年代中。
- MinorGC(新生代垃圾回收)伊甸园内存满了之后会执行复制回收算法,并将伊甸园和From存活的对象复制到To区,并将存活的对象年龄加1,并且交换From To。同时执行Stop The World(暂停其他用户进程),等垃圾回收结束才会执行其他进程
- FullGC(老年代垃圾回收)当新生代对象寿命超过阈值时,会晋升老年代。当老年代空间不足,会尝试执行Minor GC,如果仍不足,则会执行FullGC,同时执行STW,且回收时间更长。FullGC使用的是标记清除或者标记整理
- 如果FullGC之后依旧空间不足,则抛出OutOFMemory异常
- 如果存入一个比较大的对象,新生代没有办法存放--大对象存入,则会直接晋升老年代
- 另外垃圾回收器抛出异常之后不会导致进程结束
垃圾回收器
- 串行(UserSerialGC 复制
新生代+标记整理老年代)- 单线程
- 先到达一个安全点,然后进行垃圾回收,其他线程阻塞
- 堆内存小,适合个人电脑
- 吞吐量优先(并行线程执行)
- 多线程
- 同样会到达安全点,多个线程同时执行垃圾回收
- 堆内存较大,多核CPU
- 让单位时间内,STW最短
- 响应时间优先(并发CMS)
- 多线程
- 和用户线程并发执行
- 初始标记(阻塞其他线程)-->>并发标记(同时执行)-->>重新标记(全部线程全部执行重新标记)-->>并发清除(同时执行)
- 碎片过多时,导致并发失败,该垃圾回收器会退化成串行的垃圾回收器,性能严重退化。
- 堆内存较大,多核CPU
- 尽可能让单次STW的时间最短
- G1垃圾回收器---同时注重吞吐量和低延迟,超大堆内存,会将堆划分为多个大小相等的区域,整体上是标记+整理算法,两个区域之间是复制算法。
- 新生代垃圾收集--进行初始标记
- 新生代垃圾收集+并发标记
- 混合垃圾收集--会对所有区域进行全面回收
- 并发执行的FullGC并不是当老年代内存达到阈值的时候,而是并发回收垃圾的速度跟不上垃圾产生的速度,就会产生并发失败,退化回串行垃圾回收器
- 新生代---跨代引用(根对象在老年代中,会将其标记为脏Card,减少查找根对象时间)
- G1字符串去重,在新生代垃圾回收的时候,将重复的字符串对象(Char数组一致,就将其中一个进行回收,并将其中一个的引用地址返回)
类加载
将类的字节码载入方法区内,内部采用C++的一个Java描述类
类加载---加载--->链接---(验证--->准备--->解析)-->初始化
类加载器(通过GetClassLoader获取)
- 启动类加载器(JRE下的类,无法直接访问)
- 扩展类加载器(JRE中的外部类,上级为Bootstrap)
- 应用类加载器(类路径下的类,上级为External)
- 自定义类加载器(自定义--上级为Application)
双亲委派的类加载机制---上级类优先加载(从最上级开始找,递归查找。如果没有找到下级,如果到了当前级别仍旧没有就返回null)
线程上下文类加载器(MySQL驱动如果没有写的时候会自动进行类加载)
- (线程上下文类加载器,每个线程启动的时候,默认把应用程序类加载器赋值给当前线程),使用ServiceLoader加载下面的驱动类,违背了双亲委派机制。
- 使用驱动名进行匹配加载驱动类,并且使用的也是应用类加载器,同样违背双亲委派机制。
自定义类加载器(想实现不同于上述所有的前提条件时---非ClassPath下的类,或者类的不同版本)
运行期优化
JVM将执行状态分成了5层
- 0层,解释执行(解释器)
- 1层,使用C1即时编译器编译执行(不带profiling)
- 2层,使用C1即时编译器编译执行(带基本的profiling)
- 3层,使用C1即时编译器编译执行(带完全的profiling)
- 4层,使用C2即时编译器编译执行
即时编译器(JIT)与解释器的区别
- 解释器是重复执行字节码解释为机器码,JIT会将同样的字节码编译为机器码,存入CodeCache,下次遇到相同代码,直接执行。
- 解释器是将字节码解释为针对所有平台都通用的机器码,而JIT则是特定类型的机器码
优化手段(发现热点代码---hostPot)
-
逃逸分析:如果发现创建对象的操作不会逃逸(外部不会引用),C2编译器优化,直接将创建对象的字节码给替换掉
-
方法内联:如果方法较短,则直接将方法放入调用者中
-
字段优化:JMH基准测试,将成员变量的值赋值给局部变量,会减少成员变量的访问,提高吞吐量(允许方法内敛就会在机器码层次做类似的优化)
反射优化
在多次执行反射的时候,方法获取器会对其进行优化,在调用15次以上之后会将反射调用的方法复制一份执行直接调用。
JMM(Java内存模型优化)
原子性
synchronized关键字实现原子操作
- monitor
- EntryList
- Owner
- WaitSet(多线程执行wait的时候)
- Java中静态变量的自增,自减不是原子操作
- 在主存和工作内存中进行数据交换
- 多线程执行时,会将字节码进行交错执行
可见性
主内存与工作内存之间进行频繁数据交换,工作内存会将主内存中频繁获取的值放入工作内存的高速缓存中,提高性能,但是在下一个线程修改了主内存的值之后,当前线程依旧从高速缓存中获取值(旧值),产生可见性问题。
使用volatile关键字修饰,避免使用工作内存中的高速缓存,必须到主存获取。仅仅保证可见性,并不保证原子性,适用于一个线程写,多个线程读。
🌟🌟🌟
面试题:synchronized和volatile的区别是什么 ?
synchronized关键字既可以保证代码块的原子性,也可以保证代码块内变量的可见性,但缺点是重量级操作,性能较低
有序性
JIT编译器在运行时的优化(指令重排),使用volatile可以避免指令重排。JVM不影响正确性的前提下,可以调整语句的执行顺序,多线程的时候就会产生影响。
双检索单例---同样会出现指令重排的问题。
happens-before
- 线程对volatile变量的写,对接下来其他线程对该变量的读可见
- 线程start前对变量的写,对该线程开始后对于该变量的读可见
- 线程解锁m之前对变量的写,对接下来加锁的其他线程的读可见
- 线程结束前对变量的写,对其他线程得知他结束后的读可见
- 线程t1打断线程t2前的写,对打断后对该变量的读可见
CAS(乐观锁)
多个线程对一个共享的变量执行修改操作。结合CAS和volatile可实现无锁并发
竞争不激烈(如果竞争激烈,那么重试必然会频繁发生,效率会受影响),多核CPU的场景下(线程不会被阻塞,因此需要腾出CPU资源来运行重置)。
CAS底层实现
通过反射获取Unsafe类执行compareandswap
CAS原子类
🌟🌟🌟
面试题:什么是乐观锁和悲观锁
Java中的乐观锁指的是CAS:不怕别的线程来修改共享变量,如果修改了就重置
悲观锁指的是synchronized关键字,就不让其他的线程进入
JUC并发包通过CAS+volatile技术
Synchronized优化
加锁的实质就是将加锁对象的MarkWord更换成当前执行线程的记录地址
- 轻量级锁(没有竞争---一个对象有多线程访问,但是多线程访问的时间是错开的,就像学生占座,轻量级锁就像是用课本占座,如果再次访问时发现有线程访问就会将轻量级锁升级为重量级锁)
- synchronized最开始加锁执行的就是轻量级锁---乐观锁,如果一个线程获得多个锁,执行的是锁重入,解锁时只用解一次锁。
- 锁膨胀---如果当前线程获得锁之后,其他人也想加轻量级锁,但是发现已经加了锁,就会修改markword,将其升级为重量级锁,并自己进入entrylist去等待,而当前线程执行完之后回来解锁时发现不是轻量级锁,会解锁失败,但是会重新释放重量级锁
- 重量级锁(自旋重试)重量级锁在加锁之后,其他线程来访问时如果发现MarkWord已经加上了锁,不是直接阻塞线程,而是让其自旋重试进行短暂地等待,如果锁被释放,就直接使用(自旋成功);如果没有释放就陷入阻塞状态(自旋失败)。另外自旋使用的是自适应策略,如果成功了就实现高阈值。(类似于熄火等红灯,如果红灯时间超级长,就直接熄火,如果比较短,就等一会儿不熄火)
- 偏向锁(轻量级锁的锁重入显得没有必要,因此可引入偏向锁,但是偏向锁撤销带来的开销却很巨大,比如撤销时需要执行锁升级轻量级锁,另外执行hashCode时也需要撤销偏向锁)
- 优化---减少上锁时间,减少上锁粒度(将一个锁拆分成多个锁),锁粗化(将多次循环进入同步代码块优化至在同步代码块内多次循环),锁消除(如果不会被其他线程访问到,就没必要加锁),读写分离(读的是原始数组,但是写的时候写的是一个新数组)。

