Java内存分区与垃圾收集

2022-02-14/2022-10-17

Java号称一次编写,处处运行,依靠的就是java虚拟机(下称JVM)来实现的,java虚拟机并不认识.java文件,只认识.class的文件,因此我们需要将.java文件通过编译编程.class文件然后通过类加载器将类信息加载进jvm,

image-20211125145931427

image-20211125145914983

然后jvm会将类的不同组成部分划分到不同的特定功能的内存区域内,这块jvm统一管理的内存区域称为java运行时数据区,可被划分为方法区,虚拟机栈,本地方法栈,堆和程序计数器。

image-20211125150010144

运行时数据区

线程私有

程序计数器

指向当前线程正在执行的字节码指令的地址。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,一个处理器只执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储

唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

虚拟机栈

存储当前线程运行方法所需的数据,指令、返回地址

image-20211125151235831

每个方法被执行的时候,jvm同步创建一个栈帧用于存储局部变量表操作数栈动态连接方法完成出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了Java基本数据类型**(boolean、byte、char、short、int、 float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始 地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和** returnAddress 类型(指向了一条字节码指令的地址)。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩 展时无法申请到足够的内存会抛出OutOfMemoryError异常

本地方法栈

与虚拟机栈作用相似,区别只虚拟机栈执行Java方法(也就是字节码)服务,本地方法栈为虚拟机使用到的本地(Native) 方法服务。

有的虚拟机可能将虚拟机栈和本地方法栈合二为一

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失 败时分别抛出StackOverflowError和OutOfMemoryError异常。

线程公共

在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配(The heap is the runtime data area from which memory for all class instances and arrays is allocated。)

利用逃逸分析技术也有可能对象不被分配在堆上

逃逸分析:

如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁。这样垃圾收 集子系统的压力将会下降很多。

可能报java.lang.OutofMemoryError :java heap space. 堆内存溢出

方法区

用来存储已经被 Java虚拟机加载的类的结构信息,包括运行时常量池、字段和方法信息、静态变量等数据

如果方法区的内存空间不满足内存分配需求时, Java 虚拟机会抛出 outOfMemoryError 异常。

直接内存

直接内存不是虚拟机运行时数据区的一部分直接内存是直接向系统申请的内存区间。

在JDK 1.4中新加入了NIO(New Input/Output)类,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作

直接内存的分配不会受到Java堆大小的限制,但还是会受到本机总内存的限制。

垃圾回收

首先谈谈引用,因为无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

java中有四种引用(按强弱划分):

强引用

java中最强的引用,jvm宁愿outofmemory也不会回收这类引用的对象

软应用

软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常

弱引用

被弱引用关联的对象只能生存到下一次垃圾收集发生

虚引用

被虚引用关联的对象完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

引用队列

除了强引用外的其它三种引用可以和一个引用队列联合使用,如果这三种引用所引用的对象被GC回收,Java虚拟机就会把这个引用对象(而不是引用对象所引用的对象)加入到与之关联的引用队列中。

如何判断对象是否应该被回收

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

缺点:如果俩个对象互相引用就没有办法被回收或者需要做很多的额外工作才能判断回收此类对象

可达性分析

从一系列被称为“GC Roots”的节点开始根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,最后不在这个引用链上的对象可被回收。

JVM中固定作为GC Roots的对象主要包括以下几种:

1.在虚拟机栈和本地方法栈中引用的对象

2.在方法区中常量引用的对象,譬如字符串常量池里的引用。

3.静态变量

4.同步锁持有的对象

并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。如果用户线程与收集器是并发工作,可能会产生俩种后果:

一种是把原本消亡的对象错误标记为存活

另一种是把原本存活的对象错误标记为已消亡(非常严重

为了解决当用户线程与收集器是并发工作时可能产生的把原本存活的对象错误标记为已消亡的问题,

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题:

1.赋值器插入了一条或多条从黑色对象到白色对象的新引用;(因为标记为黑色的对象的引用不会再扫描)

2.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

我们只要破坏上述条件即可:

1.当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

2.当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。

注:

白色对象:表示对象尚未被垃圾收集器访问过。

黑色对象:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。即标记为黑色的对象的引用不会再扫描

灰色对象:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

怎么回收对象

要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,但此时不会立刻回收掉这个对象,而是再进行一次筛选,此时JVM会将符合下列条件的标记了的对象执行它们的finalize方法,如果对象在这个方法中将其与应用链上的对象建立联系,那在第二次标记时就会被移除即将回收的集合,反之则会被回收

2.该对象是否覆盖了finalize方法

3.是否已经执行过一次finalize

分代收集理论

1.绝大多数对象都是朝生夕灭的。

2.熬过越多次垃圾收集过程的对象就越难以消 亡。

基于分代收集理论,java堆被分为不同的区域,然后根据对象熬过垃圾收集过程的次数分配到不同的区域之中存储,一般分为新生代和老年代俩个区域

垃圾回收算法

基于可达性分析和分代收集理论有三种用于垃圾回收的算法

在实际执行中可以在新生代使用标记复制算法

在老年代中使用标记整理算法

也可以采取一种和稀泥的方法让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间,CMS收集器就是采取的这种方法

标记清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象

缺点:

内存空间的碎片化问题,标记、清除之后会产生大量内存碎片,可能会导致当需要为较大对象时分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

image-20211125194300202

标记复制算法

将内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就进行一次垃圾收集并就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间全部清理掉。

缺点:

将可用内存缩小为了原来的一半,空间浪费过多

image-20211125194459938

优化后的标记复制算法:

由于新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

于是可以把把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%

但是这样做还是有风险的,有可能经过一次垃圾回收后剩余的对象大于整个新生代容量的百分之10,因此出现了分配担保机制:

当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。

标记整理算法

先通过可达性分析进行标记再让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

image-20211125195443114

常见的垃圾收集器

image-20211127160419971

image-20211127160438504

CMS垃圾收集器工作示意图

image-20211127160803133

当CMS垃圾收集器负责的内存区域内存碎片过多时,会采用Serial Old收集器收集一次解决内存碎片问题。

并发就是STW时多个垃圾收集器线程同时工作

并行就是垃圾收集器线程和用户线程一起工作

类的生命周期

一个Java文件被加载到Java 虚拟机内存中到从内存中卸载的过程被称为类的生命周期。类的生命周期包括的阶段分别是:加载、链接、初始化、使用和卸载,其中链接包括了三个阶段:验证、准备和解析,因此类的生命周期包括了7个阶段。类的加载包括了类的生命周期的5个阶段,分别是加载、链接(验证、准备和解析)、初始化。

类的加载各个阶段所做的工作

(1)加载:查找并加载 Class 文件

具体流程为:

根据特定名称查找类或接口类型的二进制字节流。

将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构。

在内存中生成一个代表这个类的 java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

其中第一件事情就是由 Java 虚拟机外部的类加载子系统来完成的

(2)链接:包括验证、准备和解析。

验证 :确保被导入类型的正确性。

准备:为类的静态变量分配内存并设初值

解析:将常量池内的符号引用替换为直接引用。

符号引用以一组符号来描述所引用的目标,符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中

如果有了直接引用,那引用的目标必定已经被加载入内存中了。

(3)初始化:将类变量初始化为正确初始值。

对象的创建过程

1.判断对象对应的类是否加载、链接和初始化

2.为对象分配内存

内存分配根据Java堆是否规整,有两种方式。
指针碰撞:如果Java堆的内存是规整的,即所有用过的内存放在一边,而空闲的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录哪些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。
Java堆的内存是否规整根据所采用的垃圾收集器是否带有整理功能有关。

3.处理并发安全问题
有两种方式:
对分配内存空间的动作进行同步处理,比如在虚拟机采用CAS算法并配上失败重试的方式保证更新操作的原子性。
每个线程在Java堆中预先分配一小块内存,这块内存称为本地线程分配缓冲,又叫TLAB,线程需要分配内存时,就在对应线程的TLAB上分配内存,当线程中的TLAB用完并且被分配到了新的TLAB时,这时才需同步锁定。

4.初始化分配到的内存空间
将分配到的内存,除了对象头外都初始化为零值。

5.设置对象的对象头
将对象的所属类、对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。

6.执行init方法进行初始化
执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建了出来。

对象的生命周期

在Java 对象被类加载器加载到虚拟机中后, Java 对象在 Java 虚拟机中有 7 个阶段。

1.创建阶段

创建阶段的具体步骤为:

(1)为对象分配存储空间。

(2)构造对象。

(3)从超类到子类对static成员进行初始化。

(4)递归调用超类的构造方法。

(5)调用子类的构造方法。

2.应用阶段
当对象被创建,并分配给变量赋值时,状态就切换到了应用阶段。这一阶段的对象至少要具有一个强引用,或者显式地使用软引用、弱引用或者虚引用。

3.不可见阶段
在程序中找不到对象的任何强引用,比如程序的执行已经超出了该对象的作用域。在不可见阶段,对象仍可能被特殊的强引用GC Roots持有着,比如对象被本地方法栈中JNI引用或被运行中的线程引用等。

4.不可达阶段
在程序中找不到对象的任何强引用,并且垃圾收集器发现对象不可达。

5.收集阶段
垃圾收集器已经发现对象不可达,并且垃圾收集器已经准备好要对该对象的内存空间重新进行分配,这个时候如果该对象重写了finalize方法,则会调用该方法。

6.终结阶段
在对象执行完finalize方法后仍然处于不可达状态时,或者对象没有重写 finalize方法,则该对象进入终结阶段,并等待垃圾收集器回收该对象空间。

7.对象空间重新分配阶段
当垃圾收集器对对象的内存空间进行回收或者再分配时,这个对象就会彻底消失。

被标记为不可达的对象会立即被垃圾收集器回收吗?很显然是不会的,被标记为不可达的对象会进入收集阶段,这时会执行该对象重写的 finalize方法,如果没有重写finalize方法或者finalize方法中没有重新与一个可达的对象进行关联才会进入终结阶段,并最终被回收。

常见面试题

JVM内存结构说一下!

技术点:JVM内存管理
思路:分条解释每部分内存的作用,详见要点提炼| 理解JVM之内存管理
参考回答:JVM会用一段空间来存储执行程序期间需要用到的数据和相关信息,这段空间就是运行时数据区,也就是常说的JVM内存。JVM会将它所管理的内存划分为线程私有数据区和线程共享数据区两大类:
线程私有数据区包含:
程序计数器:是当前线程所执行的字节码的行号指示器
虚拟机栈:是Java方法执行的内存模型
本地方法栈:是虚拟机使用到的Native方法服务
线程共享数据区包含:
Java堆:用于存放几乎所有的对象实例和数组;是垃圾收集器管理的主要区域,也被称做“GC堆”;是Java虚拟机所管理的内存中最大的一块
方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放
引申:谈谈JVM的堆和栈差别

谈谈垃圾回收机制?为什么引用计数器判定对象是否回收不可行?知道哪些垃圾回收算法?

技术点:垃圾回收机制
思路:从如何判定对象可回收、如果回收具体算法这两方面展开谈垃圾回收机制,详见要点提炼| 理解JVM之GC
参考回答:
(1)判定对象可回收有两种方法:
引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。然而在主流的Java虚拟机里未选用引用计数算法来管理内存,主要原因是它难以解决对象之间相互循环引用的问题,所以出现了另一种对象存活判定算法。
可达性分析法:通过一系列被称为『GC Roots』的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。其中可作为GC Roots的对象:虚拟机栈中引用的对象,主要是指栈帧中的本地变量、本地方法栈中Native方法引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象
(2)回收算法有以下四种:
分代收集算法:是当前商业虚拟机都采用的一种算法,根据对象存活周期的不同,将Java堆划分为新生代和老年代,并根据各个年代的特点采用最适当的收集算法。
新生代:大批对象死去,只有少量存活。使用『复制算法』,只需复制少量存活对象即可。
复制算法:把可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用尽后,把还存活着的对象『复制』到另外一块上面,再将这一块内存空间一次清理掉。
老年代:对象存活率高。使用『标记—清理算法』或者『标记—整理算法』,只需标记较少的回收对象即可。
标记-清除算法:首先『标记』出所有需要回收的对象,然后统一『清除』所有被标记的对象。
标记-整理算法:首先『标记』出所有需要回收的对象,然后进行『整理』,使得存活的对象都向一端移动,最后直接清理掉端边界以外的内存。
引申:谈谈每种回收算法的优缺点

什么情况下内存栈溢出?

Java对象会不会分配在栈中?

如果判断一个对象是否被回收,有哪些算法,实际虚拟机使用得最多的是什么?

类加载的全过程是怎样的?什么是双亲委派模型?

工作内存和主内存的关系?在Java内存模型有哪些可以保证并发过程的原子性、可见性和有序性的措施?

技术点:JVM内存模型、线程安全

思路:理解Java内存模型的结构、详见要点提炼| 理解JVM之内存模型&线程

参考回答:Java内存模型就是通过定义程序中各个变量访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

  • 模型结构如图:image-20221017171424288

    其中 ,

    主内存

    (Main Memory)是所有变量的存储位置,每条线程还有自己的

    工作内存

    ,用于保存被该线程使用到的变量的主内存副本拷贝。为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中。

  • 保证并发过程的原子性、可见性和有序性的措施:

    • 原子性

      (Atomicity):一个操作要么都执行要么都不执行。

      • 可直接保证的原子性变量操作有:readloadassignusestorewrite,因此可认为基本数据类型的访问读写是具备原子性的。
      • 若需要保证更大范围的原子性,可通过更高层次的字节码指令monitorentermonitorexit来隐式地使用lockunlock这两个操作,反映到Java代码中就是同步代码块synchronized关键字。
    • 可见性

      (Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

      • 通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现。
      • 提供三个关键字保证可见性:volatile能保证新值能立即同步到主内存,且每次使用前立即从主内存刷新;synchronized对一个变量执行unlock操作之前可以先把此变量同步回主内存中;被final修饰的字段在构造器中一旦初始化完成且构造器没有把this的引用传递出去,就可以在其他线程中就能看见final字段的值。
    • 有序性

      (Ordering):程序代码按照指令顺序执行。

      • 如果在本线程内观察,所有的操作都是有序的,指“线程内表现为串行的语义”;如果在一个线程中观察另一个线程,所有的操作都是无序的,指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
      • 提供两个关键字保证有序性:volatile 本身就包含了禁止指令重排序的语义;synchronized保证一个变量在同一个时刻只允许一条线程对其进行lock操作,使得持有同一个锁的两个同步块只能串行地进入。

GC收集算法有哪些?他们的特点是什么?

JVM中一次完整的GC流程是怎样的?对象如何晋级到老年代? Java中的几种引用关系,他们的区别是什么?

对象诞生即新生代->eden,在进行minor gc过程中,如果依旧存活,移动到from,变成Survivor,进行标记。当一个对象存活默认超过15次都没有被回收掉,就会进入老年代。

强引用

Person person=new Person() 像这种new出来的都属于强引用,如果一个对象具有强引用,则无论在什么情况下,GC都不会回收被引用的对象。当内存空间不足时,JAVA虚拟机宁可抛出OutOfMemoryError终止应用程序也不会回收具有强引用的对象。

软引用
如果一个对象具有软引用,在内存空间充足时,GC就不会回收该对象;当内存空间不足时,GC会回收该对象的内存

Person person=new Person();
SoftReference sr=new SoftReference(person);

弱引用
弱引用具有更短的生命GC在扫描的过程中,一旦发现只具有被弱引用关联的对象,都会回收掉被弱引用关联的对象

Person person=new Person();
WeakReference wr=new WeakReference(person);

虚引用

虚引等同于没有引用,这意味着在任何时候都可能被GC回收

final、finally、finalize的区别?

final表示不可变的,它可以用来修饰类,方法和变量。
当它修饰类的时候表示该类是不能被继承的,因为抽象类就是用来被继承的,所以abstract关键字和final关键字不能共存。
当它修饰方法的时候表示该方法是不能被重写的。
当它修饰变量的时候表示该变量的值不能发生变化也就是该变量为一个常量。对于用final修饰的变量我们必须在申明它的时候赋值或者是在构造函数中给它赋值。


finally是异常处理中的一个关键字,通常的结构是这样的:
try{ } catch(){ } finally{ },
它一般用于资源释放

finalize方法是Object类的一个方法,因为所有的类都继承自Object类,所以所有的类都有finalize方法。要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,但此时不会立刻回收掉这个对象,而是再进行一次筛选,此时JVM会将符合下列条件的标记了的对象执行它们的finalize方法,如果对象在这个方法中将其与应用链上的对象建立联系,那在第二次标记时就会被移除即将回收的集合,反之则会被回收
1.此对象是否有必要执行finalize方法
2.该对象是否覆盖了finalize方法
3.是否已经执行过一次finalize

String s = new String(“xxx”);创建了几个对象

String str1 = "abc";

答案是1个或2个。
当JVM遇到上述代码时,会先检索常量池中是否存在“abc”,如果不存在“abc”这个字符串,则会先在常量池中创建这个一个字符串。然后再执行new操作,会在堆内存中创建一个存储“abc”的String对象,对象的引用赋值给s。此过程创建了2个对象。
当然,如果检索常量池时发现已经存在了对应的字符串,那么只会在堆内创建一个新的String对象,此过程只创建了1个对象。

此时的赋值操作等于是创建0个或1个对象。如果常量池中已经存在了“abc”,那么不会再创建对象,直接将引用赋值给str1;如果常量池中没有“abc”,那么创建一个对象,并将引用赋值给str1。

Java中堆和栈的区别?

技术点:内存管理

思路:从存放数据和内存回收角度出发

参考回答: 在java中,堆和栈都是内存中存放数据的地方,具题区别是:

  • 栈内存:主要用来存放基本数据类型局部变量
  • 堆内存:存放对象和数组

标题:Java内存分区与垃圾收集
作者:OkAndGreat
地址:http://zhongtai521.wang/articles/2021/11/27/1638000945250.html

评论
发表评论
       
       
取消