聊聊JVM——自动内存管理
目录
1.3虚拟机栈(Java Virtual Machine Stack)
1.4本地方法栈(Native Method Stacks)
1.5程序计数器(Program Counter Register)
1.6运行时常量池(Runtime Constant Pool)
前言:
????????上文《聊聊JVM——类加载机制》之后,在我们眼前的便是那堵由内存动态分配和垃圾收集技术所围成的高墙了——java虚拟机自动内存管理机制。
一.内存动态分配
1.运行时数据区
????????Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
Java程序在运行时,会为JVM单独划出一块内存区域,而这块内存区域又可以再次划分出一块运行时数据区,运行时数据区域大致可以分为五个部分:堆、方法区、虚拟机栈、本地方法栈和程序计数器。其中堆和方法区是所有线程共享的,而程序计数器、虚拟机栈和本地方法栈是线程私有的。
1.1堆(Heap)
????????Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创 建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。 Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用 (也就是未逃逸出去),那么对象可以直接在栈上分配内存。
1.2方法区(Method Area)
????????方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态 变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。方法区也被成为永久代,是有区别的:
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那 么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类 的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久 代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
? ? ? ? 说到方法区,那么JDK1.6、1.7、1.8的内存区域变化有必要了解一下。主要体现在方法区实现上的差异:
- JDK1.6使用永久代实现方法区,用于存储类的元数据、常量池、静态字段和方法字节码等。
- JDK1.7把原本放在永久代的字符串常量池移出放在了堆内存上
- JDK1.8完全废弃永久代,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
客观原因:使?永久代来实现?法区的决定的设计导致了Java应?更容易遇到内存溢出的问题(永久代有-XX: MaxPermSize的上限,即使不设置也有默认大小,?J9和JRockit 只要没有触碰到进程可?内存的上限,例 如32位系统中的4GB限制,就不会出问题),?且有极少数?法 (例如 String::intern())会因永久代的原因?导致不同虚拟机下有不同的表现。
主观原因:在JDK8,合并HotSpot和JRockit的代码时,JRockit从来没有一个叫永久代的东西, 合并之后就没 有必要额外的设置这么一个永久代的地方了。
1.3虚拟机栈(Java Virtual Machine Stack)
? ? ? ? 虚拟机栈的生命周期和线程相同,描述的是java方法执行的线程内存模型:方法执行时,JVM会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口和一些辅助信息。
?Main()方法是调用链的起始位置,但不一定在栈帧的底部。在main
方法内部,可能会调用其他方法,这些方法会在main
方法的栈帧之上入栈。
1.4本地方法栈(Native Method Stacks)
????????和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
????????本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError和OutOfMemoryError两种错误。
1.5程序计数器(Program Counter Register)
? ? ? ? 也被成为PC寄存器,是一块较小的内存空间,程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创 建而创建,随着线程的结束而死亡。主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
1.6运行时常量池(Runtime Constant Pool)
????????运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
1.7直接内存(Direct Memory)
????????直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。
????????JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区 (Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
????????本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
2.对象与内存分配
2.1对象的创建流程(怎么来)
JVM 中对象的创建,我们从?个new指令开始:
- 检查这个指令的参数是否能在常量池中定位到?个类的符号引?
- 检查这个符号引?代表的类是否已被加载、解析和初始化过。如果没有,就先执?相应的类加载过程
- 类加载检查通过后,接下来虚拟机将为新?对象分配内存。
- 内存分配完成之后,虚拟机将分配到的内存空间(但不包括对象头)都初始化为零值。
- 接下来设置对象头,请求头?包含了对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、 对象的 GC 分代年龄等信息。
2.2对象的内存分配(放在哪)
????????对象的内存分配有两种?式,指针碰撞(Bump The Pointer)、空闲列表(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
2.2.1指针碰撞(Bump The Pointer)
????????假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
指针碰撞方案会涉及到一个多线程分配对象导致堆抢占的线程不安全问题。
线程1分配A对象还没完成指针的修改,线程2进行B对象分配内存发生抢占。常用的保证并发安全解决方案:
-
线程本地分配缓冲(Thread-Local Allocation Buffer,TLAB):
- TLAB 是一种将堆内存划分为多个小块的策略。每个线程都有自己的TLAB,用于分配对象。这样,不同线程之间的对象分配不会发生冲突。
- TLAB减少了线程之间的内存分配竞争,提高了分配性能。
-
CAS(Compare-And-Swap):
- CAS是一种多线程编程中常用的原子操作,用于解决并发访问共享数据时的竞争问题。CAS 操作包括比较一个内存位置的当前值与指定值,如果相等,则将该内存位置的值更新为新值,否则不做任何操作。CAS 操作通常是硬件级别的原子操作,它可以确保在多线程环境下,只有一个线程能够成功修改共享数据,从而避免了竞争条件。。
- CAS 操作常用于实现非阻塞算法和并发数据结构,如无锁队列、无锁哈希表等。它是一种轻量级的同步机制,相对于传统的锁定机制,可以减少线程之间的竞争和上下文切换,提高并发性能。
2.2.2空闲列表(Free List)
????????但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分 配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“空闲列表”(Free List)。
放在哪块列表中和JVM分配策略有关,空闲列表的内存分配策略:
-
首次适应(First Fit):
- 首次适应策略会选择第一个足够大的空闲内存块来分配对象。这意味着它会选择最接近所需大小的内存块。
- 这个策略简单且易于实现,但可能导致碎片问题,因为后续分配可能无法充分利用剩余的小内存块。
-
最佳适应(Best Fit):
- 最佳适应策略会选择最接近所需大小的内存块。这通常需要遍历所有可用的内存块以找到最合适的。
- 这个策略可以减少内存碎片,但可能会导致分配性能较低,因为需要搜索可用内存块。
-
最差适应(Worst Fit):
- 最差适应策略会选择最大的可用内存块,这样后续分配可能会更容易找到足够大的内存块。
- 这个策略通常用于减少分配时的搜索开销,但可能导致更多的内存碎片。
-
分代策略:
- 分代策略将堆内存分为不同的代(Generations),如年轻代和老年代。对象通常在年轻代中分配,年轻代的分配策略通常是复制算法或其他策略。
- 老年代的分配策略可能采用不同的算法,以优化大对象的分配。
-
TLAB(Thread-Local Allocation Buffer):
- TLAB 是一种针对多线程应用的内存分配优化策略。每个线程都有自己的TLAB,用于分配内存,避免了多线程竞争分配内存的情况。
- TLAB 可以减少线程之间的竞争,提高分配性能。
2.3对象的内存布局(长啥样)
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)。
对象头主要由两部分组成:
- 第?部分存储对象?身的运?时数据:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、 偏向时间戳等,官?称它为 Mark Word,它是个动态的结构,随着对象状态变化。
- 第?部分是类型指针,指向对象的类元数据类型(即对象代表哪个类)。 此外,如果对象是?个 Java 数组,那还应该有?块?于记录数组?度的数据。
实例数据?来存储对象真正的有效信息,也就是我们在程序代码?所定义的各种类型的字段内容,?论是从?类继承的,还是??定义的。
对?填充不是必须的,没有特别含义,仅仅起着占位符的作?。
本地打印观察下:
2.4对象的访问定位(怎么找)
????????创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具 体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义 这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄和直接指针两种:
- 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就 是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图所示
- 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关 信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示。
两种对象访问?式各有优势,使?句柄来访问的最?好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是?常普遍的?为)时只会改变句柄中的实例数据指针,?reference本身不需要被修改。 使?直接指针来访问最?的好处就是速度更快,它节省了?次指针定位的时间开销,由于对象访问在Java中?常频繁,因此这类开销积少成多也是?项极为可观的执?成本。HotSpot 虚拟机主要使?直接指针来进?对象访问。
二.垃圾回收机制
1.找到需要清理的对象
????????在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。
1.1判断对象是否存活
- 引用计数(Reference Counting)算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。(单纯的引用计数就很难解决对象之间相互循环引用的问题)
- 可达性分析(Reachability Analysis)算法:是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
1.2GC Root对象说明
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- 虚拟机栈中引用的对象:譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
- 方法区中的类静态属性引用的对象:譬如Java类的引用类型静态变量。
- 方法区中的常量引用的对象:譬如字符串常量池(String Table)里的引用。
- 本地方法栈中JNI(Java Native Interface)引用的对象。
- 所有被同步锁(synchronized关键字)持有的对象。
- 虚拟机内部的引用:如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 反映引用:反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
还有一部分是某个时刻成为GC Root,但很快就会失去这个角色,从而允许被垃圾回收的临时GC Root对象,这些临时的GC Root 对象包括:
- 方法中的局部变量:在方法中定义的局部变量,例如方法的参数、临时变量等,可以成为GC Root 对象。但一旦方法执行结束,这些局部变量就会超出作用域,不再被引用,因此可以被垃圾回收。
- 临时对象引用:在某些代码块中创建的临时对象,例如在循环内部创建的对象或方法内部创建的对象,通常只在循环或方法执行期间被引用,一旦超出作用域,它们就成为不可达对象,可以被垃圾回收。
- 匿名对象:匿名对象是没有明确引用变量的对象,通常用于一次性的操作,例如创建一个匿名内部类的实例。一旦这些匿名对象不再被使用,它们就成为不可达对象,可以被垃圾回收。
- 临时引用:某些情况下,可能会创建一些临时引用变量,用于在一段时间内引用对象。一旦这些引用变量不再被使用,它们所引用的对象就成为不可达对象,可以被垃圾回收。
1.3引用类型
Java虚拟机(JVM)中有四种不同的引用类型,它们用于描述对象的可达性和生命周期,对垃圾回收器的对象回收行为产生影响。这四种引用类型分别是:
- 强引用(Strong Reference):
- 强引用是最常见的引用类型。当一个对象具有强引用时,垃圾回收器不会回收这个对象,即使内存不足时也不会回收它。
- 强引用通常用于描述必须保持对象存活的情况,例如全局变量或静态字段引用的对象。
- 软引用(Soft Reference):
- 软引用用于描述一些可有可无的对象,当内存不足时,垃圾回收器可以回收这些对象。软引用通过
java.lang.ref.SoftReference
类来实现。 - 当内存不足时,垃圾回收器会尝试回收软引用对象,但只有在回收这些对象后仍然内存不足时才会抛出
OutOfMemoryError
错误。
- 软引用用于描述一些可有可无的对象,当内存不足时,垃圾回收器可以回收这些对象。软引用通过
- 弱引用(Weak Reference):
- 弱引用用于描述不会阻止对象被回收的引用。当一个对象只有弱引用时,垃圾回收器会在下一次垃圾回收时回收这个对象。弱引用通过
java.lang.ref.WeakReference
类来实现。 - 弱引用通常用于解决一些缓存或临时引用的问题,允许对象在不再被使用时自动回收。
- 弱引用用于描述不会阻止对象被回收的引用。当一个对象只有弱引用时,垃圾回收器会在下一次垃圾回收时回收这个对象。弱引用通过
- 虚引用(Phantom Reference):
- 虚引用是最弱的引用类型,它不能用来获取对象的实例。虚引用主要用于在对象被回收时收到通知。虚引用通过
java.lang.ref.PhantomReference
类来实现。 - 虚引用通常与引用队列(ReferenceQueue)一起使用,当虚引用关联的对象被垃圾回收时,虚引用会被放入引用队列,程序可以通过监测引用队列中的虚引用来获取对象回收的通知。
- 虚引用是最弱的引用类型,它不能用来获取对象的实例。虚引用主要用于在对象被回收时收到通知。虚引用通过
1.4不推荐使用的finalize()
? ?
finalize()方法是Java中的一个特殊方法,它用于对象的垃圾回收前的清理和资源释放。finalize()方法属于java.lang.Object
类,因此所有Java类都可以重写这个方法以定义自己的垃圾回收前的清理行为。
finalize()方法的基本工作原理如下:
-
当垃圾回收器确定一个对象不再可达时,它会将该对象标记为“待回收”状态,并将在稍后的某个时间点调用该对象的finalize()方法。
-
在对象的finalize()方法中,开发人员可以编写清理代码,例如关闭文件、释放资源、断开网络连接等。
-
一旦finalize()方法被调用,对象就会被标记为已经执行过finalize(),并在下一次垃圾回收时被彻底回收。
需要注意的是,finalize()方法存在一些问题和限制:
- finalize()方法的执行时间不确定,无法保证它何时会被调用,因此不应该在其中执行关键性的清理操作。
- 在现代Java中,强烈不推荐使用finalize()方法,因为它的执行时间不确定,可能会导致资源泄漏或性能问题。取而代之的是,应该使用更可靠的资源管理方式,如 try-with-resources 块来确保资源的释放。
2.回收清理对象
2.1分代理论下的堆内存划分
????????分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分 代假说之上:1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡。这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。由于对象之间会存在跨代引用,分代收集理论添加了第三条经验法则:3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
?大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden 区->Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 - XX:MaxTenuringThreshold 来设置。
?2.2不同的GC分类
·部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
???????? ■新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
???????? ■老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
????????■混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
·整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
2.3GC流程概览
过程中需要注意对象进入老年代的场景:
- 年龄阈值: 通常情况下,对象在年轻代(Young Generation)中诞生,经过多次 Minor GC 后,如果仍然存活并且达到了一定的年龄阈值,就会晋升到老年代。年龄阈值可以通过 JVM 参数
-XX:MaxTenuringThreshold
进行配置,默认为 15 次 Minor GC。 - Survivor 区空间不足(空间分配担保): 如果 Survivor 区的空间不足以容纳存活的对象,那么这些存活对象可能会被提前晋升到老年代,而不是继续在 Survivor 区中存放。这可以通过调整 Survivor 区的大小或增加堆内存来缓解。
- 大对象: 如果创建的对象非常大,可能无法在年轻代中找到足够的连续空间来容纳它,因此这些大对象可能会直接被分配到老年代。
- 长期存活的对象: 如果某个对象的生命周期非常长,它可能经过多次 Minor GC 后仍然存活,并最终晋升到老年代。
- 动态对象年龄判定: 一些垃圾回收器(如 G1 垃圾回收器)使用动态对象年龄判定,根据对象的存活情况和 Survivor 区的空间状况来动态调整对象晋升到老年代的条件。
2.4垃圾收集算法?
2.4.1标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
- 优点:简单,不需要额外的内存空间用于复制对象。
- 缺点:会产生内存碎片,清除阶段的时间复杂度较高,可能导致停顿时间较长。
2.4.2标记-复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点:解决了内存碎片问题,将存活对象压缩到一端。
- 缺点:需要额外的内存空间来存放对象,清除和压缩的过程比较耗时。
2.4.3标记-整理算法
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内 存
- 优点:高效,不会产生内存碎片,清除阶段简单。
- 缺点:需要额外的内存空间用于复制对象,不适用于大对象。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!