先看一张图, 这是Java代码从编译到CPU运行的整个过程。 这个过程,涵盖了本篇文章的所有知识点:
- JVM的内存模型
- JVM类加载过程
- JVM的内存分配
- JVM判断对象存活原则
- JVM垃圾回收算法
JVM的内存模型
JVM的内部结构
JVM内部分成3个部分
- 类加载器:用来加载类信息,加载后型存储在jvm内存模型的方法区中
- 运行时数据区(内存模型):这里的内存模型指的就是JVM的内存模型,分为5个部分,方法区,堆,java方法栈,native方法栈,程序计数器
- 执行引擎:解释执行字节码,把字节码处理成汇编指令
知识补充: javac 是jre里面提供的工具,用来把.java文件编译成.class的字节码文件。 字节码文件经过jvm处理之后变成汇编指令。 汇编指令再加载到设备的主存之中,后变成机器指令由CPU执行。 所以, 只要是能够编译成.class字节码文件,就能由java虚拟机解释执行,实现跨平台。 kotlin groovy、JRuby、jython 都编译字节码由jvm解释执行。
知识补充:为什么jvm执行的文件叫做字节码, 这是因为jvm每次执行指令大小就是1个字节。这个字节里面包括了操作码和操作数。操作码就是运算符,操作数就是要进行运算的数。
内存模型
根据是否共享线程数据可分为2大区域
- 线程可共享的数据: 方法区数据, 堆数据
- 线程独享的数据:java方法栈数据, native方法栈数据, 程序计数器
线程可共享数据, 在jvm内存中, 只存在一份, 方法区和堆数据都只有一份。
而线程独享的数据, java方法栈、native方法栈和程序计数器每个线程都存在一份。
方法区存储哪些数据
类信息、常量、静态变量存储在方法区
堆栈存储哪些数据
方法中成员变量的存储
如果是java基础类型, 则变量和值存储在方法栈中。
如果是引用类型, 变量存在方法栈中,值,也就是对象实例,存储在堆中。
全局变量的存储
全局变量, 也就是类中的变量。
不管是基础类型还是引用类型, 成员变量和值都存储在堆中。
所以大部分的对象实例都是存在堆内存中的, 只有方法中的基础类型成员变量和其值,引用变量这三种存储在栈内存中。JVM的GC主要针对的也是堆内存的回收,所以也被叫做GC堆。
java、native方法栈的作用
java是基于线程执行的, 每一个线程都有对应的方法栈和程序计数器。 在方法执行时, jvm会把方法打包成一个栈帧,栈帧里面包含了方法的全部信息, 方法每调用一次,就会在当前线程的方法栈创建一个栈帧并入栈,方法执行结束后,出栈。 java方法在java方法栈出入栈 ,native方法在native方法栈出入栈。
栈帧存储了以下4个区域的信息
- 局部变量表,记录局部变量
- 操作数据,记录操作数据
- 动态连接,确定具体的方法
- 返回地址,返回程序计数器的地址
程序计数器的作用
程序计数器用来记录当前线程下一条运行的指令, 因为cpu时间片切换可能导致当前线程未执行完就切到其他线程。
类加载过程
类加载的5大流程
类加载过程有5步
- 加载
- 验证
- 准备
- 解析
- 初始化
类加载5大流程中的具体工作
-
加载:把类信息加载到jvm的内存模型中的方法区
-
验证:验证类型信息是否符合jvm要求的规范
-
准备:分配变量内存和初始化值
-
解析:把符号引用处理为直接引用
-
初始化:合并类变量和静态语句块
内存分配
内存分配指的是从堆中分配出一块大小合适的内存块给对象。
有两种方式进行堆中的内存分配
- 指针碰撞
- 空闲列表
指针碰撞
如果堆中内存分配是规整的,则只需要将记录内存的指针移动对象所需大小的位置。
空闲列表
如果堆中内存分配是不规整的,则需要记录哪些内存块已使用, 哪些内存块未使用。 从未使用的内存块中找出合适大小的分配给对象。
内存分配是否规整, 取决是jvm使用的垃圾回收器,带压缩整理的回收器,内存分配是规整的。
判断对象是否存活
判断对象是否存活有两种方法
- 引用计数法
- 可达性分析法
引用计数法
给对象设置一个引用计数器,通过引用计数器来记录引用对象的地方, 引用+1,引用失效-1。为0时,代表对象已“死去”。
可达性分析法
可达性分析就是通过GC Roots 作为根节点向下搜寻,找出可达和不可达的对象。
GC Roots
GC Roots就是对象,并且是当前不可被回收的对象。
哪些对象可以作为GC Roots对象?
- 静态变量引用的对象
- 常量引用的对象
- java栈帧本地变量表引用的对象
- native栈帧本地变量表引用的对象
- 被同步锁锁定的对象
为什么以上对象可作为GC Roots?
-
静态变量和常量都存储在方法区中。在进程运行期间,不会被回收。
-
前面说过栈帧的存储, 在方法执行时, jvm会把方法打包成一个栈帧入栈, 栈帧存储了方法里面的变量。 只要方法没结束, 里面变量所引用的对象就不会被回收
-
当前线程持有对象锁,对象也不能被回收, 如果回收,对象锁就失效了。
对象引用类型
可达性分析中,提到了对象的引用,在Java中,有4种引用类型
- 强引用
- 软引用
- 弱引用
- 虚引用
特点
- 强引用(new Object):只要还在存在引用,就不会被回收, 手动置null后可被回收
- 软引用(SoftReference):内存不够用,要触发oom时会被回收
- 弱引用(WeakReference):只要触发GC就会被回收
- 虚引用(PhantomReference):对象要被回收时,会存入到给定的那个队列里面
GC时处理引用对象,就像是我们整理房间大扫除一样,强引用就像是生活中的必需品,只要还需要用, 就不会被我们当做垃圾扔掉。 而软引用就是那种占地方,又不是生活必需品的东西, 当房间的空间不足的时候,我们就会考虑扔掉它。 弱引用就是房间里面的垃圾,每次大扫除我们都会扔掉它。 虚引用就是现在不用,以后可能会用的东西, 在打扫时, 我们会把这些东西(对象)放到储物柜(消息队列)收纳起来,需要的时候再从储物柜里拿出来使用。
使用场景
- 强引用:日常业务逻辑开发,成员变量,局部变量
- 软引用:实现缓存类业务
- 弱引用:避免内存泄漏,handler,dialog
- 虚引用:NIO中使用虚引用管理堆外内存
垃圾回收算法
垃圾回收算法有复制和标记两种思想的算法,具体实现有以下算法:
- 复制算法(1比1 复制、 8:1:1复制)
- 标记-清除算法
- 标记-整理算法
- 新生代-老年代分别处理
复制算法
将堆内存1:1分成2块,每次只使用其中一块, 在回收时把存活的对象复制到另外一块,再把原来那块内存全清空。
这样做的问题是空闲浪费比较大。 所以在JVM采用的8:1:1的方式来实现内存分配和回收。对象创建的时候分配在8和其中的一个1上面, 回收时,把存活的对象复制到另外一个1上面,清空原来的8和1。
标记清除算法
标记需要回收的对象,标记完成统一回收。
在标记确认对象可回收阶段,具体实现是:
当对象通过可达性分析确认为不可达之后, 如果不必要执行finalize方法,就直接判定为可回收。 如果有必要执行finalize方法, 则放入一个的集合。
在这个集合里面的对象, 会被执行每一个对象的finalize方法,再执行finalize方法的时候, 会再次对该对象进行可达性分析, 如果不可达, 判定为可回收。 可达则保留并放入即将回收的集合。
标记整理算法
和标记清除算法差不多,只是在清除前进行一次整理,再清除。
具体实现是:把所有存活的都行都向一端移动进行整理,整理完成后,清除掉边界以外的内存。
新生代老年代收集算法
新生代和老年代
根据对象的存活周期, 把堆内存分为新生代和老年代,分配比例为1:2。
新创建的对象一般都在新生代区域。大对象则直接被分配到老年代,比如很长的字符串或数组。
在新生代里面经过15次GC还存在的对象, 会被移动到老年代。
新生代和老年代使用的算法
根据新生代和老年代对象的特性:新生代的对象回收频率最高,老年代的回收频率较低。
复制算法更适用于新生代,因为大部分对象生命周期短,复制过去的对象比较少。
标记整理算法更适用于老生代,因为老年代对象生命周期短,需要做的标记和整理的工作少。
什么时候触发GC
-
在创建对象的时候, eden空间不足的时候。 触发Minor GC, 对新生代内存区域GC。
-
老年代内存区域被写满,或者手动调用Systemt.gc()触发FullGC,对整个堆内存进行回收。
总结
-
在Java代码被javac编译成字节码之后,JVM开始处理字节码文件。
-
JVM的类加载器开始类加载过程,经过5个步骤:加载,验证, 准备,解析,初始化。
- 加载过程把类加载到JVM内存模型的方法区中
- 验证过程校验类信息是否符合JVM规范
- 准备过程开始分配内存和初始化值
- 解析过程把间接引用转换为直接引用
- 初始化过程合并类变量和静态语句块
-
在分配内存时,根据变量的类型,位置,把变量和这个变量的值分配到方法区,堆内存,或者栈内存。
-
当新生代内存不足时触发Minor GC,对新生代内存区域进行内存回收。当老年代内存区域内存不足时,触发 Full GC, 对整个堆内存进行Full GC。 也可以手动调用Systemt.GC(); 开启堆内存回收。
-
内存回收时,先要判断对象是否存活,在使用垃圾回收算法来回收内存区域中已经死亡的(没在使用的)对象。判断对象是否存活有两种方法:
- 一种是引用计数法,给对象分配一个引用计数器,对象被引用一次就+1,取消引用就-1。 当引用计数器的值为0的时候表示对象已死亡。
- 一种是可达性分析法, 通过GC Roots向下搜索,找出不大到达GC Roots的对象,这种对象就是不可达的。 在经过标记和筛选之后判定这些对象是否存活。
-
知道那些对象需要被回收之后, 通过回收算法,复制算法或者标记-清除, 标记-整理算法,回收内存上已经死亡的对象。
-
复制算法就是把内存1比1分成两份或者8比1比分成3份。 每次分配内存时只使用其中的一份, 回收时, 把这份上还存活的对象复制到另外一份。 然后清理这份的整块区域。
-
标记清除/标记整理 算法, 就是对内存块上的对象状态进行标记,在内存回收阶段,清除已死亡状态的对象。
-
根据对象的生命周期,会把堆内存按1比2分配成新生代内存区域和老年代内存区域。 新生代内存区域使用复制回收算法。老年代内存区域使用标记整理算法。因为新生代的生命周期短,回收频率高。所以标记工作量大,不适合标记整理算法。 而适合整块复制、清除的复制算法。
-