JAVA内存管理

1 Java内存区域

在这里插入图片描述

1.1 java虚拟机管理的内存各个区域

1.1.1 程序计数器

  • 这是一块占比比较小的内存空间,有学习过《操作系统》这门课程的同学应该很熟悉,它是用来记录下一条要执行的指令的地址,在java虚拟机中,字节码解释器就是通过改变这个计数器的数值来选取下一条需要执行的字节码指令,线程如果是正在执行一个java方法,那么计数器记录的是正在执行的方法,如果执行的是本地方法,这个计数器的值就为空。
  • 程序计数器是线程私有的,每一条线程都需要独立的程序计数器来记录线程执行的状态。
  • 在多线程并发的情况下,Java虚拟机是通过把处理器的时间分成一片一片分给不同的线程让线程轮流切换执行实现的,其实在同一时刻只有一个线程在执行,但从一段时间(很多个时间片合起来)来讲,就像是有多个线程并行在执行一样。为了让线程切换后能恢复到原来的执行位置,使各个线程相对独立,所以每个线程需要独立拥有一个程序计数器来完成线程恢复的工作。程序计数器除了控制恢复线程,还可以控制分支,循环,跳转,异常处理等。

1.1.2 虚拟机栈

  • java虚拟机栈也是线程私有的,一个虚拟机栈对应一个线程,创建线程的时候对应的虚拟机栈也被创建,它的生命周期跟线程一样
  • 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,这个栈帧存储了局部变量表操作数栈、动态连接、方法出口等信息。每个方法被调用和执行结束对应着入栈和出栈,如下图。
    在这里插入图片描述
  • 局部变量表用于存储方法的参数和方法内部定义局部变量,容量单位为变量槽(slot),一个变量槽占用32位或者64位又虚拟机实现决定,如果是32位的变量槽,那么64位的变量long,double占用两个变量槽

1.1.3 本地方法栈

本地方法栈和虚拟机栈非常相似,区别就是虚拟机栈是为虚拟机执行java方法而服务,而本地方法栈是为虚拟机执行本地方法而服务,HotSpot虚拟机将虚拟机栈和本地方法栈合而为一了。
HotSpot虚拟机是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

1.1.4 堆

  • 堆是虚拟机所管理占比最大的一块内存区域,它是所有线程共享的一块内存区域,在虚拟机启动时创建。
  • 此内存的唯一目的就是存放对象的实例(《深入理解java虚拟机——周志明》中提到由于即时编译技术的进步,尤其是逃逸技术分析技术的日渐强大,栈上分配、标量替换优化手段已经导致了一些微妙的变化悄然发生,所以java对象实例都分配在堆上也渐渐变得不是那么绝对了,对这方面有兴趣的读者可以自行深入去研究)
  • 堆是垃圾收集器管理的内存区域,也称为GC堆
  • 从分配内存的角度来看,所有线程共享的java堆中可以划分出多个线程私有的缓冲区TLAB,目的就是为了提升对象分配时的效率。究竟如何分配如何提升效率下篇文章会继续讲解,本文先简单概述各个区域
  • 《java虚拟机规范》规定堆可以处于不连续的内存空间中。但多数虚拟机实现出于实现简单,存储高效考虑,很可能会要求连续的内存空间。
    -《 java虚拟机规范》规定堆的大小可以固定也可扩展,主流java虚拟机都是按照可扩展来实现的(通过设置参数-Xmx和-Xms)

1.1.5 方法区

  • 方法区和堆一样是线程共享的区域,它用于存储被虚拟机加载类的信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 方法区是堆的一个逻辑部分,但是它却有一个别名“非堆”,目的就是要把它跟堆区分开
  • 常量
    字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;
    符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。
  • 常量池分类
  1. 类文件常量池:又称为静态常量池,编译时产生对应的class文件,主要包含字面量和符号引用;
  2. 运行时常量池:它是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
    1.JDK1.7之前版本运行时常量池包含字符串常量池位于方法区。
    2.JDK1.7版本字符串常量池位置从方法区搬到了堆中; 运行时常量池还在方法区。
    3.JDK1.8hotspot永久代被元空间(Metaspace)取代, 字符串常量池位置还在堆中, 运行时常量池位置变成了元空间(Metaspace)。
  3. 字符串常量池:类在加载、验证、准备完成后在堆中生成字符串对象实例,然后将该字符串对象实例的引用只存储到Sting Pool中,String Pool是一个StringTable类,是哈希表结果,里面存储的是字符串引用,具体的实例对象存储在堆中,这个stringtable表在每个hotspot中的实例只有一份,被所有类共享。
  4. 基本类型包装类常量池:
  • 永久代:指的是永久保存区域,方法区中的信息一般需要长期存在,在JDK8之前方法区习惯被称之为永久代。
  • 元空间:HotSpot虚拟机在1.8之后,类的元信息被存储在元空间中,永久代被完全废弃。元空间和永久代类似,都是对JVM中规范中方法的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存的限制。类的元数据放入native memory,字符串池和类的静态变量放入java堆中。这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。
  • 直接内存:这个并不是虚拟机运行时数据区的一部分,也不是《java虚拟机规范》中定义的内存区域,但是这部分内存也被频繁使用,也有可能会导致OutOfMemoryError异常

1.2 HotSpot虚拟机

1.2.1 创建对象

  • 当java虚拟机遇到一条字节码new指令时,1.检查这个指令的参数是否能在常量池中定位到一个类的符号引用,2.检查这个符号引用代表的类是否已经被加载、解析、初始化过。如果没有那必须先执行相应的类加载过程(以后的文章再详细探讨类如何加载)
  • 类加载通过后,接下来虚拟机将为新生对象从中划分一块确定大小的内存。分配多大的内存在类被加载之后就可以确定(怎么确定呢?)
  • 指针碰撞:如果堆的内存是绝对规整的(一边是已用的,一边是空闲的,中间放一个指针作为分界点),那么分配内存就只要把指针往空闲区域的内存移动,这种分配方式就称为“指针碰撞”
  • 空闲列表:如果堆的内存不是绝对规整的,那么就需要维护一张表记录哪些是空闲区域,分配内存的时候先查空闲列表,找到一块足够大的空间划分给对象的实例,并更新这张空闲列表,这种方式就称为“空闲列表”
  • 选择哪种方式由堆是否规整决定,而堆是否规整又由采用的垃圾收集器空间压缩整理能力决定
  • 由于创建对象在虚拟机是非常非常频繁的行为,在并发环境下会出现很多线程安全的问题。比如可能出现正在给A分配内存,A的指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。
    解决这个问题有两种解决方案
  1. 分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
  2. 还记得上面讨论堆的时候提到“线程私有的TLAB”吗,他的中文名称叫“本地线程缓冲(Thread Local Allocation Buffer)”,每个线程会预先在堆中分配一块很小的线程私有内存,这块内存就是TLAB,哪个线程需要分配内存就先在自己的TLAB中分配,当TLAB不足,分配新的内存才需要同步锁定。虚拟机是否使用TLAB可以通过-XX:+/–UseTLAB参数来设定
  • 分配得到的内存再次划分成如下图结构
    在这里插入图片描述
    将分配到的内存中的示例数据块初始化为零,如果使用TLAB可以在TLAB分配时顺便进行。这步操作使得对象的实例字段在JAVA代码中可以不赋初始值就直接使用,使得程序能访问到这些字段的数据类型对应的零值
  • 接下来java虚拟机还要对对象设置这个对象是属于哪个类的实例,如何才能找到类的元数据信息,对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算),对象的GC分代年龄等信息。这些信息存放在对象头中,关于对象头的具体内容后面再继续详细探讨。
  • 上面的工作完成之后,对于虚拟机来说一个新的对象就创建完成了,但是,从java程序的视角来说,对象的创建才刚刚开始,因为构造函数还没有执行,所有的字段都还是默认零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好,按照程序员的意愿(也就是构造函数)对对象进行初始化之后,一个真正可用的对象才完全的被创建出来。

1.2.2 对象的内存布局

在HotSpot虚拟机里,对象在堆内存中存储的布局可用划分为三个部分:对象头,实例数据,对齐填充。如上节图所示。

  • 对象头包括两类信息(数组多一类用于记录数组长度的数据,因为虚拟机可用通过普通对象的元数据信息确定java对象的大小,但是数组的长度是不确定的,无法从元数据推断数组的大小)
  1. 用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,这部分的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称为“MarkWord”
  2. 类型指针,即对象指向它类型元数据的指针,通过这个指针来确定这个实例是属于哪个类,但是并不是所有的虚拟机都会在对象数据上保留类型指针,也就是说不一定要通过对象本身去查找对象的元数据
  • 实例数据部分,这部分是对象真正存储的有效信息,即我们在代码中定义的各种类型的字段内容,无论是从父类继承还是子类定义的都必须记录起来,这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义的顺序影响。HotSpot虚拟机设定的默认顺序是long/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
  • 对齐填充,这部分不是必然存在的,也没特别的含义,仅仅作为占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说创建对象分配的内存大小是8字节的整数倍,由于对象头部分已经被精心设计成8个字节的整数倍,因此数据部分没有对齐的话就要通过对齐填充来实现对齐。

1.2.3 对象的访问定位

java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《java虚拟机规范》值规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方法区定位访问堆中的对象,所以对象的访问方式也是由虚拟机定的,主流的访问方式主要有句柄和直接指针两种。

  • 句柄,java堆中划出一块内存作为句柄池,reference存储就是对象的句柄的地址,句柄包含了实例对象数据和类型数据各自的地址信息,如下图所示。
    在这里插入图片描述
  • 直接指针,reference中直接存储对象的地址,对象的类型信息存储在对象所分配的内存中,如上节介绍的对象头的第二部分中。如下图所示。
    在这里插入图片描述
    这两种方式各有各的优势,句柄最大的好处就是reference中存储的是稳定的句柄地址,对象移动(垃圾收集时移动对象是非常频繁的行为)的时候只会改变句柄的实例指针,而reference本身不需要修改,直接指针最大的好处就是速度更快,节省了一次指针定位的时间。HotSpot主要使用的是直接指针方式(有例外情况,比如使用了Shenandoah收集器的话也会有一次额外的转发,具体下篇文章讨论)

2 垃圾收集GC

垃圾收集需要完成3件事情

  1. 哪些内存需要回收
  2. 什么时候回收
  3. 怎么回收
  • 虚拟机栈,本地方法栈,程序计数器都是线程私有的,他们的生命随线程而生,随线程而死。在这三个区域就不需要考虑内存回收的问题,因为随着方法或者线程结束,内存自然而然就回收了。
  • 堆和方法区不确定性比较强,一个接口多个实现类需要的内存可能会不一样,一个方法执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

2.1 对象的存活与死亡

垃圾收集器在对堆进行回收之前,第一件要做的事就是确定这些对象哪些存活,哪些死亡(没有被引用),下面介绍两种判断对象是否死亡的算法。

2.1.1 引用计数算法

在对象中添加一个计数器,每当有一个地方引用它时就加一,当有引用失效时就减一,为零就是死亡。
这种算法原理简单,判断效率高,但是java虚拟机“没有”选用这个算法来管理内存,原因是这个算法有很多例外情况需要考虑,需要配合大量额外处理才能保证正确的工作,比如对象之间相互循环引用的问题

2.1.2 可达性分析算法

通过一系列称为“GC Roots”的跟对象作为起始节点集,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,如果某个对象到GC Roots间没有引用链相连,这个对象就是已死亡。如下图
在这里插入图片描述

  • 作为GC Roots的对象主要有以下几种
  1. 在虚拟机栈(栈中的本地变量表)中引用的对象
  2. 在方法区中类静态属性引用的对象,比如java类的引用类型静态变量
  3. 在方法区中常量引用的对象,比如字符串常量池里的引用
  4. 在本地方法栈中JNI引用的对象
  5. Java虚拟机内部的引用,比如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointException,OutOfMemoryError),还有系统类的加载器
  6. 所有被同步锁持有的对象
  7. 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等。
  8. 根据用户所选用的垃圾收集器一级当前回收的内存区域不同,还有其他“临时性”的加入
  • java,C#都是通过可达性分析算法来判断对象是否死亡。
  • 即使在可达性分析算法中判断为不可达对象,也不是马上就判定该对象是死亡对象,而是先进行一次标记,随后再进行一轮筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。如果这个对象呗判定为“有必要执行finalize方法”,那么该对象会被放置在一个名为F-queue的队列之中,并在稍后由一条由虚拟机创建,低调度优先级的Finalizer线程去执行它们的finalize方法。接着收集器将会对F-queue中的对象进行第二次标记,如果对象在finalize方法中成功拯救自己(只要重新和引用链上任何一个对象建立关联即可)

2.1.3 四种引用关系

  1. 强引用是最传统的引用关系,是指在java代码中普遍存在的引用赋值,类似“Object obj = new Object”这种引用关系。无论什么情况下,只要强引用关系还存在,垃圾收集器就不会回收掉被引用的对象
  2. 软引用是用来描述一些还有用,但是不是必须的对象。只被软引用关联对象,在系统将要发生内存溢出异常前才会把这些对象列进回收范围之中进行第二次回收,如果这次回收内存还不够的话才会抛出内存溢出的异常。
  3. 弱引用是用来描述那些非必须的对象,强度比软引用弱一级,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
  4. 虚引用是最弱的一种引用关系

2.1.4 回收方法区

  • 方法区主要回收两部分内容:废弃的常量和不再使用的类型。回收常量和回收堆中的对象非常相似。
  • 在堆中,特别是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%~99%的内存空间,相比之下方法区的回收判定条件更加苛刻,回收成果远远低于堆。

2.2 三种垃圾收集算法

2.2.1 分代收集理论

  • 分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上
    弱分代假说:绝大多数对象都是朝生夕灭
    强分代假说:熬过多次垃圾收集的对象就越难死亡
    把分代收集理论放到现在商用的java虚拟机上,设计者一半至少会把堆划分为新生代和老年代两个区域,垃圾收集器每次只回收其中某一个或者某部分区域,因而有了“minor GC”,“major GC”,“full GC”类型的收集器
  1. minor GC:指目标只是新生代的垃圾收集
  2. major GC:指目标只是老年代的垃圾收集
  3. full GC:指对整个堆和方法区的垃圾收集
  • 跨代引用假说:跨代引用对于同代引用来说只占极少数
    根据这条假说,进行一次新生代区域收集的时候,扫描到被老年代引用的对象之后没必要去扫描整一个老年代区域来确保可达性分析算法的正确性,而是把老年代分成若干个小块,在新生代区域建立一个记忆集,标识老年代哪一块存在跨代引用,发生minorGC时,只有标记存在跨代引用的老年代区域里的对象才会被扫描,虽然需要增加在对象引用关系改变时维护记录数据的正确性的开销,但比起扫描整个老年代更划算。

2.2.2 标记-清除

算法分为“标记”和“清除”两个阶段,首先标记需要回收的对象,在标记完成后,同意回收掉所有被标记的对象。也可以反过来,标记存活的对象,清除未被标记的对象。有两个缺点,一是效率不稳定,二是内存碎片化。执行过程如下图
在这里插入图片描述

2.2.3 标记-复制

将可用内存分为大小相等的两块,每次只使用其中一块,当这一块空间用完了就把存活对象复制到另一块,然后再把这一块空间全部清除。缺点很明显,可用空间缩小成原来的一半了。如下图
在这里插入图片描述
根据弱分代假说(绝大多数对象都是朝生夕灭),appel提出一种更优化的半区复制分代策略,现在称为“appel回收”,Hotspot虚拟机的Serial,parNew新生代收集器均采用这种回收策略,下篇文章介绍各种收集器再详细说明这两种收集器。 将新生代按8:1:1的比例分为一块较大的Eden空间和两块较小的survivor空间,每次分配内存只使用eden和其中一块survivor空间,垃圾收集时就把存活对象复制到另一块survivor空间,如果不够存放就通过分配担保机制将一部分对象分配到老年代中,然后直接清理eden和那块survivor空间。

2.2.4 标记-整理

标记过程跟“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清除,而是让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。如下图
在这里插入图片描述
如果存活的对象比较多,比如在老年代区域,移动存活对象并更新所有引用这些对象的地方是极为负重的工作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这种停顿被描述为“stop the world”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值