JVM四部分:类加载子系统、运行时数据区、执行引擎、本地库接口
Java程序经过Java编译器编译成字节码文件,通过类加载器加载到内存中(运行时数据区),然后通过执行引擎将字节码翻译成特定操作系统的指令集,并且这个过程会调用一些不同语言给Java提供的接口(本地库接口)
Java运行时数据区
- 程序计数器 , 一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,线程私有,每个线程一个。不会OOM
- Java虚拟机栈 , 也是线程私有,栈里面放的是栈帧。一个栈帧对应一个方法,一个方法调用到执行完毕对应栈帧出栈入栈。栈帧里有局部变量表、操作数栈、动态链接、方法出口等信息,会OOM。
- 本地方法栈 ,和虚拟机栈类似,只不过虚拟机栈执行Java方法,本地方法栈执行Native方法,Native方法权限大,和JVM权限一样
- 堆空间 ,所有线程共享的一块内存区域,但是堆中还会划分一些小的TLAB线程本地分配缓冲区,几乎所有对象都在堆中分配,会OOM
- 方法区 ,也是所有线程共享的内存区域,主要存的是类型信息、常量、静态变量、即时编译器编译后的代码缓存等
- 运行时常量池 :方法区的一部分,用于存放编译期生成的各种字面量和符号引用
- 直接内存 :直接内存并不是虚拟机运行时数据区的一部分, 占用虚拟机之外的本机内存,如果jvm内存分的太多了,然后直接内存和JVM内存加起来超过物理内存限制,会导致动态扩展时出现OOM
类加载过程
加载->链接(验证+准备+解析)->初始化(使用前的准备)
- 加载:查找并加载类的二进制数据生成Class对象。
- 链接
验证:确保被加载类的正确性
准备:为类的静态变量分配内存,并将其初始化为默认值(零值)
解析:把类中的符号引用转化为直接引用 - 初始化
就是执行类构造器方法clInit()的过程。 clInit是ClassInit缩写,多线程下ClassInit方法会被同步加锁。
对象初始化顺序
父类static –> 子类static –> 父类顺序执行- -> 子类顺序执行【变量、初始化块、构造方法】
对象实例化过程与内存布局
- 加载类元信息
- 为对象分配内存
- 处理并发问题
- 属性的默认初始化(零值)
- 设置对象头的信息【类的元数据信息、hashCode、GC信息、锁信息】
- 属性显式初始化、代码块中,构造器中初始化
对象内存分配算法,那种好?
指针碰撞算法:如果Java堆中内存是绝对规整的,所有使用过的内存都在堆的一边,未使用的在另一半,中间放一个指针作为分界点,然后来一个内存,指针就朝空闲区域移动和对象大小想等的距离。这种算法简单高效,但是必须保证采用的垃圾收集器带有空间压缩整理的能力,比如Serial,ParNew等带压缩整理过程的收集器。
空闲列表:如果Java堆内存并不规整,已被使用的内存和空闲内存相互交错在一起,JVM必须维护一个列表,记录哪块内存是可用的,在分配的时候找一快足够大的空间分给对象实例,并更新空闲列表上的记录。当使用CMS这种基于标记清除算法的收集器,理论上就只能采用较为复杂的空闲列表算法
对象在JVM中的内存布局
对象在堆中存储布局包括三部分: 对象头、实例数据、对齐补充
- 对象头 :①存储对象 自身运行时数据 :如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
② 类型指针 ,通过这个指针可以确定对象的类型 - 实例数据 :对象真正存储的有效信息,定义的各种类型的字段内容,从父类继承的和子类定义的字段都必须记录下来
- 对齐补充 :对其到 8 的整数倍字节
对象怎么分配
逃逸分析: 如果一个对象在方法里面被定义,并且他可能被外部方法所引用(例如作为参数传到其他方法中)这种称为方法逃逸。还有可能 线程逃逸 ,比如赋值给其他线程中能访问的实例变量。不逃逸、方法逃逸、线程逃逸是对象不同程度的逃逸
分配原则:
- 栈上分配 :不逃逸的对象可能进行栈上分配,对象类型可能进行标量替换
- 大数数情况下在堆中的 Eden区分配 ,如果开启了 TLAB ,就在Eden区中的TLAB区分配
- 大对象直接放老年代 ,超过参数设置值的对象直接在老年代分配
- 长期存活的放老年代 ,对象在Survivor区每熬过一次Minor GC 年龄就加1,到一定程度(默认15)就进入老年代
- 动态对象年龄判断 ,如果Survivor中相同年龄所有对象的大小总和大于空间的一半,则、这些年龄或大于等于这些年龄的对象直接进入老年代
- 空间分配担保 ,Minor GC前会看看老年代最大可用连续空间够不够放下新生代的所有对象,如果能则确保本次Minor GC 是没有风险的。如果开启了允许担保失败,那么垃圾收集器会检查老年代最大可用连续空间是不是大于历次晋升到老年代对象的平均大小。如果大于就尝试进行一次Minor GC, 如果小于或没有允许担保失败就Full GC
并发创建对象的安全性
多线程对对象进行创建,可能出现正在给A分配内存,指针还没来得及修改,对象B由同时使用原来的指针来分配内存的情况。
有两种可选方案:
- 对分配内存的动作做同步处理——JVM采用CAS(比较并交换,底层原理是自旋锁)加上失败重试的方式保证更新操作的原子性
- TLAB, 每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲,这样每个线程会在自己的TALB中给对象分配内存,不会冲突
垃圾回收机制
- 新生代收集(Minor GC / Young GC): 只是新生代(Eden、s0 、s1)的垃圾收集
当年轻代的Eden区满的时候会触发Minor GC, - 老年代收集(Major GC / Old GC):只是老年代的垃圾收集
目前,只有CMS GC 会有单独的收集老年代的行为
很多时候Major GC 会和Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收 - 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
目前只有G1 GC 会有这种行为 - 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
新对象往Eden区放,放不下就YGC,YGC还放不下就放Old,Old放不下就FGC,还放不下就报错了。
YGC之后Eden区就清空了,不是垃圾的对象放s0/s1区,不是垃圾的太多了,s0/s1放不下那就方法Old区
新建对象放Eden区,超过参数设置的对象直接放老年代。Eden区满了并且老年代剩余空间大于新生代中的对象就进行Minor GC
s0/s1 区采用的复制算法进行垃圾回收,空的那个区可以叫做 to 区, 有对象的那个叫from区
Minor GC就是把 Eden区的存活对象放 to 区, from区的对象也放 to 区,然后给对象增加GC年龄,如果to区满了就直接放老年代,如果对象年龄够了,或者满足动态年龄判断了 也把对象放老年代。
如果老年代剩余空间不大于新生代中的对象, 就看有没有开启空间分配担保,如果开启了就看剩余空间够不够历次MinorGC后剩余的对象大小,够就进行一次有风险的Minor GC, 失败了就Full GC。没开担保直接FullGC
垃圾回收算法
引入计数法
当一个对象被引用,对象的引用计数就加1,失去引用就减1,当值为0,就表示该对象不被引用,可以被垃圾收集器回收。
引用计数法有一个弊端,就是不能释放循环引用的变量。
可达性分析
可达性分析算法通过一系列堆外对象作为GCRoots, 从这些节点向下搜索,搜索过的路径称为引用链,当一个对象不能和GCRoots连接,就说明此对象不可达。不可达对象处于“缓刑阶段”,还需要两次标记过程来宣告一个对象的死亡
不可达对象第一次标记: 如果对象没有重写finalize方法,或已经执行过finalize方法,则该对象被回收。如果有必要执行 finalize 的方法 (重写了且没执行过),则对象放在F-Queue队列,并稍后由虚拟机建立的低优先级Finalizer线程触发该对象的finalize() 方法
不可达对象第二次标记: GC对 F-Queue 队列里的对象进行第二次标记,如果第二次标记时该对象有成功被引用了,则会被移除即将回收的集合。否则被回收
由于finalize()方法的存在,虚拟机中的对象一般处于三种可能状态:
- 可触及的:从根节点开始,可以到达这个对象
- 可复活的:对象的所有引用都被释放,但是对象可能在finalize() 方法中复活
- 不可触及的:对象的finalized() 被调用,并且没有复活,就会进入不可触及状态
finalized()只会被调用一次,复活过的对象,无法再次被复活,死了的对象,就不用说了
Java程序如何运行的?
Java源代码经过Java编译器编译成字节码文件后,通过类加载器加载到内存中,然后在JVM中解释执行,(JIT编译和解释执行配合),通过操作系统CPU执行获取结果
1.常用工具
- jmap: 生成堆快照(heapdump)
- jstack: 用于 JVM 当前时刻的线程快照, 又称 threaddump 文件, 它是 JVM 当前每一条线程正在执行的堆栈信息的集合。
- jconsole: 可视化工具, 用于对 JVM 中的内存、 线程和类等进行监控;
- jvisualvm: 可视化工具, JDK 自带的全能分析工具, 可以分析: 内存快照、 线程快照、 程序死锁、 监控内存的变化、 GC 变化等。
2.CPU 占用过高如何诊断
- 用 top 命令查看 CPU, 内存等占用情况, 定位哪个 PID (进程号) 对 cpu 或内存的占用过高。
- 查看 PID 对应的线程号 (NID), 进一步定位是哪个线程引起的 cpu 占用过高
top -Hp PID
- 由于 jstack 使用的是 16 进制线程号, 需要先将线程号转化为 16 进制
printf ‘%x’ PID
- 再使用 jstack 工具定位有问题的线程
jstack NID
3.永久代或元空间会发生垃圾回收吗?
在JDK1.7,永久代是属于Java堆内存的,如果方法区满了,会触发Full GC
JDK1.8之后,方法区的实现变成了元空间,是属于直接内存,不会发送GC
4.方法内的局部变量是否线程安全?
- 如果方法内局部变量没用逃离方法的作用域,每个线程是独享栈内存的,各个栈内存互相不会干扰,是线程安全的
- 如果局部变量引用了对象,并且指向栈外,逃离了方法的作用域,需要考虑线程安全问题。
5.堆空间是分配对象存储的唯一选择吗?
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化
如果经过逃逸分析后发现,一个对象并有逃逸出去的方法的话,那么就可能被优化成栈上分配,
这样就无需再堆上分配内存,也无需垃圾回收,这是最常见的堆外存储技术。
6.JVM堆内存溢出后,其他线程是否可以继续工作
可以继续工作,堆内存要溢出的时候,该线程就会被释放。JVM会进行一次Major GC把该线程相关资源都回收掉,其他线程便可以继续工作。
7. 谈谈JVM的类加载器以及双亲委派模型怎么打破?
类加载器 :就是根据全限定类名将class文件加载到JVM内存,转化为Class对象
- 启动类加载器 :加载lib目录下的类,开发人员不能直接获取到启动类加载器的引用。
- 扩展类加载器 :lib/ext 目录下,可以由开发人员直接使用
- 应用程序类加载器 :将classpath路径下指定的类加载到内存,也可以由开发人员使用
- 自定义类加载器 : 自定义的类加载器继承自 ClassLoader, 并覆盖 findClass 方法, 它的作用是将特殊用途的类加载到内存中。
双亲委派机制:
如果一个类加载器收到类加载的请求,他首先不会自己尝试区加载这个类,而是把这个请求委派给父类加载器完成,每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类的时候(ClassNotFoundException),子加载器才会尝试加载。
为什么需要双亲委派机制:
JVM把 加载器相同且类名相同的类判定是一个类。如果没有双亲委派模型,假如用户自己编了一个java.lang.Object类, 就会出现多个不同的Object类,Java类的唯一性就无法保证
如何打破双亲委派机制
继承ClassLoader类,重写loadClass和findClass方法
8.三种垃圾算法原理及特点
复制算法
内存分为容量大小相等的两块,每次只用一块,然后回收的时候就把存活的对象复制到另一颗,然后这块全部清除掉
特点:不会有内存碎片,占用两倍的内存空间
标记清除
标记阶段:标记所有需要回收的对象
清除阶段:标记完成后,统一回收所有被标记的对象
标记整理
先执行标记清除,在将内存整理成连续的空间
9. 内存泄漏
内存泄漏:程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占着内存
内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用,短生命周期的对象已经不在需要,但是由于长生命周期的对象依然持有它的引用,一直不能被回收。
避免内存泄漏的几点建议:
- 尽早释放无用对象的引用
- 避免在循环中创建对象
- 使用字符串处理的时候避免使用String,应使用StringBuffer
- 尽量减少静态变量,因为静态变量存在方法区,基本不参与回收
10. 内存溢出?
内存溢出是程序运行过程中申请的内存大于系统能提供的内存,导致无法申请到足够内存,发生内存溢出。
哪些地方会发生OOM:
- Java堆溢出
- 虚拟机栈和本地方法栈溢出
- 方法区和运行时常量池溢出
- 本地内存溢出
内存溢出解决方案:
- 修改JVM参数,增加增加内存
- 检查错误日志,查看OutOfMemory 错误前是否有其他异常或者错误
- 对代码进行分析检查,找出可能发生溢出的位置
- 使用内存查看工具动态的查看内存使用情况