JVM(二)方法区,堆,垃圾回收算法

本文以HotSpot为例,不同虚拟机可能在实现上有所不同Java的堆(Heap)是一种用于动态分配内存的运行时数据区域。堆空间主要用于存储对象实例和数组对象。

堆空间的变化

java7以及之前堆的定义分为 新生区(新生区可以新分为伊甸园区幸存者区) 老年区 永久代  

java8后堆的定义分为 新生区(新生区可以新分为伊甸园区幸存者区) 老年区 元空间 

永久代(Permanent Generation)是方法区的一部分,位于堆空间中。它用于存储类的结构信息、常量、静态变量、即时编译器(Just-In-Time Compiler,JIT)生成的代码等。但是,从 Java 8 开始,永久代被移除,并由元空间(Metaspace)取代。

元空间是在 Java 8 引入的概念,用于存储类的结构信息、常量、静态变量等,但它位于本地内存(Native Memory),而不是堆空间中。因此,永久代不再存在于 Java 8 及之后的版本中。

元空间永久代是堆方法区的实现

对象分配过程

  当创建一个新的对象时,对象会先存放在Eden区,当Eden区内存不够时,会触发minorGC,(垃圾清理把没有被引用的对象空间释放,后面会具体讲),把Eden区的对象先存放在s0区域,对象对应的age会加一,当age大于指定数额默认是15,对象还存在就说明这个对象是经常被使用的,就把此对象放入老年代。当老年代空间不足时会进行majorGC或者FullGC,majorGC主要是针对老年区进行垃圾回收,FullGC是对整个堆空间进行垃圾回收。由于垃圾回收时需要进行停止用户线程,即STW(stop the world),相比较来说MinorGC速度比较快,这里堆的内存划分体现了分代思想,提高了整体效率。值得注意的是s0,s1区不会主动触发垃圾回收,是由Eden区触发垃圾回收同时对s0,s1区进行垃圾回收,s0和s1只有一个空间会被使用,比如此时使用的s0,那么下次垃圾回收使用的就是s1。

TLAB

 由于一个堆空间是进程中所有的线程共享的,容易在分配内存时造成不安全问题,加锁的方式有影响性能,所以有了TLAB,JVM为每一个线程分配了一个私有的缓存区域,在Eden区,同时这种方式提高了内存分配的吞吐量,称为“快速分配策略”;

TLAB空间很小默认占1%,当对象尝试在TLABH中分配失败时,会采用加锁方式保证操作的原子性

jvm默认采用懒加载方式,当创建对象时会先检查类信息有没有加载,进行通过类加载器进行加载,如上图所示

逃逸分析

逃逸分析(Escape Analysis)是一种编译器优化技术,用于确定对象的生命周期是否超出了当前方法的范围。通过分析对象的逃逸情况,编译器可以对程序进行一些优化,例如栈上分配(Stack Allocation)和标量替换(Scalar Replacement)等。

逃逸分析的主要目标是识别那些在方法内部创建并且不会逃逸出当前方法的对象,这些对象可以安全地分配在栈上,而不需要在堆上进行动态内存分配。栈上分配可以带来一些性能优势,因为栈上分配的对象具有更短的生命周期和更快的访问速度。

另一方面,逃逸分析还可以帮助编译器进行标量替换的优化。标量替换是将对象拆分为其成员变量的单个标量值,并将其存储在寄存器或栈上的过程。这样可以减少对堆内存的依赖,提高程序的执行效率。

逃逸分析需要在编译阶段进行,通常由即时编译器(Just-In-Time Compiler,JIT)执行。它通过静态和动态的分析技术来确定对象的逃逸情况。静态逃逸分析通过静态代码分析来分析对象的可见性和引用传递情况。动态逃逸分析则在程序运行时收集信息,例如方法调用图和线程间的共享对象等。

逃逸分析的优化效果取决于具体的编译器和运行时环境。在某些情况下,逃逸分析可以显著提高程序的性能,减少内存分配和垃圾回收的开销。然而,并非所有情况下都能进行有效的逃逸分析,因此优化效果可能因代码结构和编译器实现而异。

判断逃逸分析最直接的方法就是看变量的作用范围是否超出了方法的范围

基于逃逸分析的代码优化

首先要开启逃逸分析参数-XX:+DoEscapeAnalysis

栈上分配

栈上分配(Stack Allocation)是一种内存分配方式,将对象分配在调用栈上而不是堆上。在栈上分配的对象有以下特点:

  1. 生命周期受限:栈上分配的对象的生命周期与所在的方法或代码块的执行范围相对应。一旦方法或代码块执行结束,对象将被自动释放,无需进行垃圾回收操作。

  2. 快速分配和释放:与堆上的动态内存分配相比,栈上分配的速度更快。在栈上分配对象只需要简单地移动栈指针,而不需要耗费额外的时间来搜索和管理堆空间。

  3. 内存局部性:栈上分配的对象存储在连续的内存区域中,这提供了更好的内存局部性。这样可以减少缓存的不命中率,提高程序的执行效率。

栈上分配的条件是对象的生命周期被限制在方法或代码块内部,不会逃逸到方法外部。逃逸分析是用于确定对象是否逃逸的技术,如果编译器可以确定对象不会逃逸,则可以选择在栈上进行分配。

需要注意的是,栈上分配并不适用于所有类型的对象。栈空间有限,它主要用于存储局部变量、方法参数、返回值以及方法调用和返回的相关信息。对于较大的对象或需要在方法外部访问的对象,仍然需要在堆上进行分配。因此,编译器会根据逃逸分析的结果和对象的特性进行决策,决定对象是在栈上分配还是在堆上分配。

HotSpot虚拟机未采用

同步省略(锁消除)

同步省略(Synchronization Elimination)是一种编译器优化技术,用于在多线程程序中减少或消除不必要的同步操作。

在多线程程序中,为了保证线程安全性,我们通常使用同步机制(如synchronized关键字或锁)来确保共享数据的一致性。然而,并非所有的代码都需要同步,有些代码块在并发环境下是线程安全的,因此可以对其进行同步省略。

编译器通过静态和动态的分析技术来确定哪些代码块是线程安全的,从而可以安全地省略同步操作。静态同步省略是通过静态代码分析来分析代码的数据流和控制流,以确定是否存在数据竞争的可能性。动态同步省略则是在程序运行时收集信息,例如线程间的共享数据访问情况,以确定是否需要进行同步操作。

同步省略可以带来一些性能优势,因为同步操作通常涉及锁的获取和释放,这会引入一定的开销。通过省略不必要的同步操作,可以减少线程之间的竞争,提高程序的并发性能。

public void test(){
   MyLock lock = new MyLock();
   synchronized(lock){
    System.out.println("hello");  
   }
}

类似这种代码,不会出现并发安全问题,加锁等于没加开启逃逸分析后会把锁优化掉

标量替换

标量替换(Scalar Replacement)是一种编译器优化技术,用于在某些情况下将对象拆解为其各个字段(标量),并将这些标量分别存储在寄存器或栈上,而不是作为整个对象存储在堆上。

在传统的对象模型中,对象通常以连续的内存块形式存储在堆上。然而,对于某些小型对象或方法局部变量,它们的字段可能独立于其他代码的上下文,并且可以被单独处理。在这种情况下,编译器可以将对象拆解为标量,并将其分别存储在寄存器或栈上,以提高访问效率和减少内存访问的开销。

标量替换的优点包括

  1. 减少内存访问:通过将对象的字段存储在寄存器或栈上,可以减少对堆内存的访问,从而提高程序的执行效率。

  2. 提高局部性:标量替换可以提高内存局部性,因为相关的字段在存储时是连续的,这有助于减少缓存的不命中率。

  3. 启用其他优化:标量替换可以为其他优化技术提供更多的机会,例如死代码消除和复制传播等。

开启标量替换:-XX:+EliminateAllocations

比如一个Person对象有age和name两个属性,当我们new Person(18,"xiaoming")时,可以优化为

int age = 18;String name = "xiaoming";这样就不用在堆中存储,而是分配在栈帧中。

方法区

方法区(Method Area)是Java虚拟机(JVM)的一个重要组成部分,它用于存储类的结构信息、静态变量、常量以及编译器生成的字节码等数据。

方法区是JVM规范中定义的一块内存区域,它在JVM启动时被创建,并且在JVM关闭时被销毁。方法区是线程共享的,所有线程都可以访问其中的数据。

方法区主要用于存储以下内容:

  1. 类的结构信息:方法区存储了类的完整结构信息,包括类的字段、方法、构造函数、父类、接口等。这些信息在类加载时被加载到方法区,并且在整个程序的运行过程中都保持不变。

  2. 静态变量:类的静态变量(static变量)被存储在方法区中。静态变量是与类关联而不是与对象关联的,它在程序启动时被初始化,并且在整个程序的生命周期内存在。

  3. 常量池:常量池是方法区的一部分,用于存储编译期生成的各种字面量和符号引用。常量池包括字符串字面量、类和接口的全限定名、字段和方法的名称和描述符、方法句柄、方法类型等。

  4. 字节码:编译器生成的字节码被存储在方法区中。这些字节码包括类的方法体、方法的操作码、操作数以及异常处理表等。

 

常量池(Constant Pool)

常量池是Java类文件中的一部分,它用于存储编译期生成的各种字面量和符号引用。每个类都有一个常量池,其中包含了类中使用的常量、字段和方法的符号引用等信息。

jdk1.6以及以前:静态变量存放在永久代

jdk1.7:字符串常量池,静态变量都保存在堆中

jdk1.8以及目前:类型信息,字段,方法,常量保存在本地内存的元空间。字符串常量池,静态变量都保存在堆中。

常量池包括以下内容:

  1. 字符串字面量:常量池中存储了字符串常量的字面值,例如"Hello"

  2. 类和接口的全限定名:常量池中存储了类和接口的全限定名,用于在运行时加载和解析类。

  3. 字段和方法的名称和描述符:常量池中存储了字段和方法的名称和描述符,用于在运行时访问和调用字段和方法。

  4. 符号引用:常量池中存储了对其他类、字段、方法的符号引用,用于在运行时解析和链接这些符号引用。

运行时常量池(Runtime Constant Pool)

运行时常量池是JVM在加载类文件时,将类文件中的常量池数据转存到内存中的一部分。它是方法区的一部分,用于在运行时支持常量池的动态解析和链接。

与类文件中的常量池相比,运行时常量池具有以下特点:

  1. 动态性:运行时常量池可以动态添加和删除常量。例如,通过字符串的拼接、反射等方式可以在运行时生成新的字符串常量并添加到运行时常量池中。

  2. 引用的解析和链接:运行时常量池中存储的符号引用需要在运行时解析和链接,以获取真正的内存地址。这包括类和接口的解析、字段和方法的解析、方法的动态绑定等。

  3. 空间共享:多个类的常量池中可能包含相同的字符串字面量或符号引用,运行时常量池可以共享这些相同的常量,以节省内存空间。

总结而言,常量池是存储在类文件中的一部分,用于存储编译期生成的各种字面量和符号引用。运行时常量池是方法区的一部分,用于在运行时支持常量的动态解析和链接。运行时常量池具有动态性和引用的解析特性,并且可以共享相同的常量。

在Java虚拟机中,每个类都有自己的运行时常量池,而不是多个类共用一个运行时常量池。

每个类在加载到JVM时,都会将其类文件中的常量池数据转存到内存中作为该类的运行时常量池。这样做是为了在运行时能够动态解析和链接常量的引用,并提供对类、字段和方法的访问。

尽管多个类的常量池中可能包含相同的字符串字面量或符号引用,但它们在运行时仍然是各自独立的。这是因为每个类的常量池中的常量是特定于该类的,与其他类的常量池没有直接的共享关系。

然而,需要注意的是,运行时常量池中的字符串字面量可以共享。当多个类的常量池中包含相同的字符串字面量时,它们可以共享同一个字符串对象,以节省内存空间。这是由于字符串在Java中是不可变的,可以安全地共享。

综上所述,尽管每个类都有自己的运行时常量池,但字符串字面量可以在多个类之间共享。这种共享是通过字符串池(String Pool)机制实现的,而不是通过多个类共用一个运行时常量池。StringTable是对字符串常量池的一种实现。StringTable的位置转移提高了垃圾回收的效率

垃圾回收

主要分为两大阶段 标记阶段  清除阶段

常见的标记算法有两种

 引用计数算法

引用计数算法(Reference Counting Algorithm)是一种内存管理算法,用于跟踪对象的引用数量并在引用数量为零时自动释放内存。它的基本思想是为每个对象维护一个计数器,记录当前指向该对象的引用数。

引用计数算法的工作原理如下:

  1. 引用计数器:每个对象都有一个引用计数器,用于记录当前指向该对象的引用数量。初始时,计数器为零。

  2. 引用增加:当有新的引用指向对象时,引用计数器加一。

  3. 引用减少:当一个引用不再指向对象时,引用计数器减一。

  4. 引用计数为零:当对象的引用计数变为零时,表示没有任何引用指向该对象,可以确定该对象不再被使用。

  5. 内存释放:当对象的引用计数为零时,内存管理系统可以立即回收该对象占用的内存空间,并将其标记为可重新分配的。

引用计数算法的优点是简单高效,能够及时释放无引用的对象,避免内存泄漏。然而,引用计数算法也存在一些问题:

  1. 循环引用:当存在循环引用时,即多个对象相互引用形成环状结构,引用计数算法无法准确地判断对象是否可回收,导致内存泄漏。为解决这个问题,需要额外的垃圾回收算法来检测和处理循环引用。

  2. 开销大:引用计数算法需要维护每个对象的引用计数器,增加和减少引用时都需要更新计数器,这会带来一定的额外开销。

  3. 线程安全:引用计数算法在多线程环境下需要采取额外的措施来保证计数器的正确性,例如使用原子操作或加锁,否则可能导致计数器不准确。

对于引用计数解决循环依赖可以进行手动释放

java并未采用这种算法,而是采用了可达性分析算法

可达性分析算法(Reachability Analysis Algorithm)

可达性分析算法(Reachability Analysis Algorithm)是一种垃圾回收算法,用于确定对象是否可达(reachable)并进行相应的内存回收。它的基本思想是通过从一组根对象GC Root出发,遍历对象之间的引用关系,标记出所有可达的对象,未被标记的对象即为不可达的垃圾对象。

可达性分析算法的工作流程如下:

  1. 根对象:在Java中,根对象包括正在执行的线程的栈帧中的本地变量、静态变量以及被特殊指针(例如类加载器)引用的对象。这些根对象是内存回收的起点,算法从这些根对象出发进行遍历。

  2. 遍历对象图:从根对象开始,通过对象之间的引用关系遍历对象图。对于每个访问到的对象,将其标记为可达。

  3. 标记阶段:在遍历过程中,将所有可达的对象标记为可达状态,未被标记的对象即为不可达的垃圾对象。

  4. 清除阶段:在标记阶段完成后,垃圾回收器可以根据已标记的对象进行内存回收。即清理所有未被标记的对象,并将回收的内存空间重新分配给新的对象使用。

可达性分析算法的优点是能够准确地识别出不可达的垃圾对象,并进行相应的内存回收。它可以处理循环引用的情况,只有不可达的对象会被回收,从而避免了内存泄漏。然而,可达性分析算法也存在一些缺点:

  1. 开销大:可达性分析算法需要遍历对象图,对大型的对象图和对象数量较多的情况下,算法的开销会相对较大。

  2. 暂停应用程序:在标记和清除阶段,垃圾回收器需要暂停应用程序的执行,以进行标记和清理操作。这可能导致一定的停顿时间,影响应用程序的响应性能。

常见的GC Roots

 虚拟机栈帧中引用的对象

 本地方法栈JNI中引用的对象

 方法区中类的静态属性引用的对象

 方法区中常量引用的对象

 synchronized持有的对象

 java虚拟机内部引用的对象:系统类加载器 基本数据类型的Class对象

 根据垃圾回收的区域不同,还可以有一些其他的根对象:

 假设只回收新生代垃圾,那么这些区域对象可能被位于其他区域的对象引用

标记-清除算法

 当堆空间不够时,会暂时停止用户线程STW,此时进行标记和清除

标记;Collector从根对象遍历,标记所有被引用的对象,一般是在对象头的Header中记录为可达对象

清除:对堆中所有对象进行遍历,没有标记为可达对象的被删除

优点:实现简单

缺点:效率不够高,会有碎片化空间,需要维护一个空闲列表

复制算法

这种方式通过利用两块空间,把所有标记为可达对象通过A空间移动到B空间,通过指针碰撞的方式把数据规整起来,解决了碎片化空间的问题。这就是为什么年轻代要有s0,s1区。

这种方式对空间的要求比较大,同时由于是复制,所以要维护例如栈桢中对对象引用的地址

这种算法适合于存活对象不多的场景,否则复制就显得没有意义!而年轻代大多数对象都是朝生夕死,所以比较适合。

标记压缩算法

优点:通过把标记对象收集,然后把对象存放到内存的一端,这样就解决了内存碎片化的问题

缺点:效率上低于复制算法,需要调整对象的引用的地址,执行期间需要暂停用户程序

总结:没有最好的算法只有最适合的算法

  • 5
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值