JVM内存管理深度剖析
JVM与操作系统的关系
以HelloWord.java程序为例,简单介绍一下java程序的执行过程
- HelloWord.java通过javac编译成字节码文件HelloWorld.class
- 通过类加载器ClassLoader将字节码文件加载到运行时数据区(这个会在后面介绍)
- 通过执行引擎执行并与操作系统交互
关于Java SE体系结构
- JVM只是一个翻译工具
- JRE提供了基础类库
- JDK提供了工具
JVM
运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域
- 程序计数器
- 虚拟机栈
- 本地方法栈
- Java堆
- 方法区(运行时常量池)
线程私有的:虚拟机栈、本地方法栈、程序计数器
- 线程共享的:堆、方法区
程序计数器和栈
程序计数器是JVM内存区域中 唯一不会OOM的内存区
局部变量表只能存储8大数据类型(
byte[1]、short[2]、int[4]、long[8]、float[4]、double[8]、char[2]、boolean[1])+引用
一个线程有多个方法
一个方法一个栈帧
操作数栈:存放方法的执行和操作。
下面通过一个简单的1+2=3的计算过程解释局部变量表与操作数栈之间的关系
- 操作数栈将局部变量表中的1、2拷贝入操作数栈
- 操作数栈弹出1、2并计算得3压入操作数栈
- 之后又压入局部变量表
关于动态链接的理解:
口述不清,咱们以实例来理解
下图中Man与Woman为Person类的子类都有wc的实现方法
引用首先指向Man,执行wc方法
当引用指向新的对象调用wc方法,但是这俩wc并不一样,系统却能调用正确的方法,这就是动态链接的作用。
本地方法栈
- 本地方法栈保存的是native方法的信息
- 当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机中创建栈帧,JVM只是简单地调用动态链接库并直接调用native方法。
方法区和堆
首先解决一个问题,为什么堆和方法区都是线程共享的,但却分成两份?
1. 堆中存放的是数组和对象,需要频繁回收。
2. 方法区中存放的有类信息、常量、静态变量、即时编译器编译后的代码
深入辨析堆和栈
- 功能
- 以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;
- 而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;
- 线程独享还是共享
- 栈内存归属于单个线程,每个线程都有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
- 堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。
- 存储空间
- 栈的内存要远远小于堆内存,栈的深度是有限制的,可能发生StackOverFlowError问题。
堆的内存模型
- 堆的内存空间分为Eden(新生代)、From区和To区(也可划分到新生代)、Tenured(老年代)
- 对象被分配到的是连续的内存空间,也就是说上述的几个区域都是连续的内存空间。
这里先浅谈一下堆的内存模型,后面在讲解对象的分配存储时将深入。
虚拟机优化技术
编译优化技术
方法内联
比如有的场景,可以用一个表达式代替一个Boolean判断方法。
栈帧之间的数据共享
对象的创建
对象的创建过程(一般对象)
分配内存
内存划分引发的并发编程问题(指针碰撞) 解决方案:
CAS加锁
本地线程分配缓冲TLAB(Thread Local Allocation Buffer):
给每个线程分配一块区域(一般在Eden区)
内存空间初始化
先初始化默认值(不需要赋值就可以使用,当然程序可以越早使用对象那么效率越高)
设置
设置对象头信息
对象的初始化
- 执行构造方法
关于对象的访问定位
- 使用句柄
- 引用reference不再存对象地址,而是存对象所对应的指针(句柄),句柄池再映射一次。
- 句柄池具有指针定位的开销,开销比较小。
- 直接指针
- 直接指向真实的地址(Hotspot以及一些主流的虚拟机就使用这种)
垃圾回收机制(GC)
GC是不会主动触发的,内存不足时触发。
引用计数法
引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
可达性分析算法
可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。
在Java中,可作为GCRoot的对象包括:
方法区:类静态属性的对象;
方法区:常量的对象;
虚拟机栈(本地变量表)中的对象;
本地方法栈JNI(Native方法)中的对象。
使用可达性分析算法,对象一定会被GC吗?
答案是否定的,那就要涉及到finalize这个Object万类之主中的方法
finalize与try catch finally
finalize
finalize()只能执行一次,且优先级较低
finalize()不一定能救活对象
所以使用try finally救活对象更好。
try catch finally
- 不管有没有出现异常,finally块中的代码一定会执行
- 当try和catch中有return时,finally仍然会执行
- finally是在return后面的表达式运算后执行的(此时并没有返回运算后的值,而是先把要返回的值保存起来,不管finally中的代码怎么样,返回的值都不会改变,仍然是之前保存的值),所以函数返回值只在finally执行之前确定的
- finally中最好不要包含return,否则程序会提前退出,返回值不是try或catch中包含的返回值。
四种引用
强引用:就像我们平时使用的“=”
软引用(SoftReference):
当OOM时被回收
- 软引用测试:
第一次测试结果:
第二次测试结果:
- 软引用的实际使用:图片加载
弱引用(WeakReference):
只要GC就被回收
- 实际应用:一些不是很重要的东西,比如缓存。可以用来防止内存泄漏
- 实际过程中弱引用使用的更多,因为发生GC的频次肯定比OOM的频次高
虚引用(PhantomReference):
最弱的:随时会被回收,GC时会收到一个通知
实际应用:监控垃圾回收器是否正常工作
对象分配策略
几乎所有的对象都在堆中分配
- 所以不是所有的对象都是在堆中分配,未发生逃逸的对象内存其实是分配在栈上
- 首先会判断是否在栈上分配
栈上分配 -> 虚拟机栈
- 栈中运行完了,不需要垃圾回收,栈是跟随线程存在。
逃逸分析
堆中的优化技术
- 本地线程缓冲(TLAB)
如果是大对象(很长的字符串、数组)
- 放入到老年代(Tenured)中
长期存活的对象进入老年代
- 首先进入From区,然后接下来的GC中在From和To区移动(复制回收算法,只要把复制的对象传递过去,就可以对内存区域直接处理,效率高)
- age最大15,超过15将进入老年代,age也可以自定义
- From区与To区等大
- 大数据显示,大部分的新生对象中90%以上会在第一次GC被回收,剩下的才进入From区,所以可以让我们的空间利用率达到90%以上(并且空间比例Eden:From:To=8:1:1)。
空间分配担保
From区与To区空间不够用,进入老年代(Tenured区)
由JVM做担保,不用fullGC(GC在堆中分为minorGC->Eden、From、To和fullGC->Tenured),空间够用
如果担保失败(空间不够用),那么首先需要fullGC,然后再存储对象到老年代
如此处理,就不用再每次向老年代存储对象时都fullGC
分代收集理论
- 绝大多数对象都是“朝生夕死” ->新生代
- 对象熬过了很多次垃圾回收,越难回收 ->老年代
- fullGC也会回收包括方法区在内的空间,即使方法区中的内容很难回收(静态变量、常量…)
复制算法(即前面的复制回收算法)
实现简单、运行高效
内存复制、没有内存碎片
利用率只有一半(预留一半进行复制)
Appel式回收
即前面提到的,Eden区与From、To区分配比例大致为8:1:1(理由上面提及过,第一次回收时90%以上的都会被回收,只有不到10%才能进入From区)
这样使空间利用率提高了,提升至90%。
标记清除算法
- 即在回收前标记需要回收的空间
- 执行效率不稳定(可能有90%需要回收、可能10%需要回收)
- 内存碎片导致提前GC
标记-整理算法(Mark-Compact)
是标记清楚算法缺点的一种解决方案,但同样也存在明显缺陷。
- 对象移动
- 引用更新
- 用户线程暂停
- 没有内存碎片
JVM中常见的垃圾回收器
- 单线程垃圾回收器
- 多线程并行垃圾回收器
- 多线程并发垃圾回收器:支持垃圾回收线程与用户线程 同时工作。
CMS
Concurrent Mark Seep并发标记清除
- 减少了STW(Stop The World)的时间
- 只针对老年代
标记阶段
- 初始标记(暂停所有用户线程):标记GCRoots直接相连的对象,所以速度很快。
- 并发标记:标记GCRoots所有关联的对象。
- 重新标记(暂停所有用户线程):将中间有变动的重新标记(并发收集是GC不干净的,所以需要重新标记),时间短。
清理阶段
- 并发清理(用户和GC同时进行):时间长
- 重置线程
缺点:
- CPU敏感:用户线程、GC线程都要跑,如果CPU核心数不足,那么对用户影响会很大
- 浮动垃圾:并发清理时,用户线程还是会产生垃圾
- 内存碎片
常量池与String
最后再来谈一个易混淆的知识点,String,同时介绍一个常量池。
静态常连池
所谓静态常量池,即*.class文件中的常量池,class文件中的常量池不仅包含字符串(数字)字面量,还包含类、方法的信息,占用class文件绝大部分空间。
运行时常量池
而运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,咱们常说的常量池,就是指方法区中的运行时常量池。
String
String str = new String(“abc”);
intern
在调用intern方法之后,会去常量池中查找是否有等于该字符串常量对象的引用,有就返回引用。