Java内存分配与垃圾收集

Java内存分配与垃圾收集

  • Java运行时数据区域
  • HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程
  • Java垃圾收集器
  • Java内存分配策略
Java运行时数据区域

  Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁时间。

程序计数器
  • 它可以看成是当前线程所执行的字节码的行号指示器。
  • 由于Java虚拟机的多线程是通过线程的轮流切换并分配处理器的执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,所以这部分内存是“线程私有”的。生命周期同线程相同。
  • 如果线程正在执行Java方法,则该计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Native方法,则该计数器值为空。
  • 该内存区域是唯一在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。
Java虚拟机栈
  • 为虚拟机执行Java方法服务。
  • Java虚拟机栈是“线程私有”的,它的生命周期与线程一样。
  • 每个方法在执行的同时都会创建一个“栈帧”,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至完成的过程,就对应一个“栈帧”在虚拟机栈中入栈和出栈过程。
  • 局部变量表所需空间在编译期间完成分配,其存放了编译期可知的各种基本数据类型,对象引用和return类型。
  • 如果线程请求的栈深度超过虚拟机所允许的深度,将抛出StackOverflowError。
  • 如果虚拟机栈可以动态扩展,当扩展时申请不到足够的内存,就会抛出OutOfMemoryError。
本地方法栈
  • 为虚拟机执行Native方法服务。
  • 有的虚拟机(如 HotSpot)直接就把本地方法栈和虚拟机栈合二为一。
  • 本地方法栈会抛出StackOverflowError和OutOfMemoryError。
  • 生命周期同线程相同。
Java堆(GC堆)
  • Java堆是被所有线程共享的一块内存区域。
  • 此内存区域用来存放对象实例,几乎所有的对象实例都在这里分配。
  • 从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆可以细分为:新生代和老年代。
  • 从内存分配角度来看,线程共享的Java堆可能划分出多个线程私有的分配缓冲区(TLAB)。
  • Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
  • 如果在堆中没有内存完成实例分配,并且堆也无法在扩展时,将会抛出OutOfMemoryError。
方法区
  • 它是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 不需要连续的内存,可以选择固定的大小或者可扩展,还可以选择不实现垃圾收集。
  • 这部分区域的内存回收的目标主要是针对常量池的回收和对类型的卸载。
  • 当方法区无法满足内心分配需求时,会抛出OutOfMemoryError。
运行时常量
  • 运行时常量池是方法区的一部分。
  • Class文件中有类的版本,字段,方法,接口,常量池等信息,常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载进入方法区的运行时常量池中存放。
  • 运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,也就是说并非预置入Class文件中常量池的内容才能进入方法区运行时常量池。
  • 当常量池无法再申请到内存时,就会抛出OutOfMemoryError。
直接内存
  • 在JDK 1.4中新加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的应用进行操作。
  • 当使用直接内存导致总内存超过物理内存时,会出现OutOfMemoryError。
HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程
对象的创建
  1. 虚拟机执行一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行相应的类的加载过程。
  2. 类加载通过检测后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。根据Java堆中内存是否绝对规整,内存分配方式可以分为“指针碰撞”和“空闲列表”。对于分配对象内存空间时的线程安全性问题,有两种解决方案:一种是对分配内存空间的动作进行同步处理-实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(本地线程分配缓冲TLAB)。
  3. 内存分配后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作可以提前到TLAB分配时进行。
  4. 接下来,虚拟机对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码和对象的GC分代年龄等信息。这些信息存放在对象的对象头中。

以上对象的创建只是虚拟机完成的部分,从Java程序角度看,程序员还需要按意愿去完成自己需要的初始化过程。

对象的内存布局

  对象在内存中的存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头。包括两部分信息:一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等。该部分会根据对象的状态复用自己的存储空间。另一部分是类型指针,即对象指向它类元数据的指针,虚拟机通过这个指针来确实这个对象是哪个类的实例。
  • 实例数据。对象真正存储的有效信息,也是在程序中定义的各种类型字段内容。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
  • 对齐填充。并不一定存在,它只起到占位符的作用。
对象的访问定位

  Java程序需要通过栈上的reference数据来操作堆上的具体对象。Java虚拟机规范中只规定了reference类型是一个指向对象的引用。主流的访问方式有使用“句柄”和“直接指针”两种。

  • 使用句柄,则Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。优势是稳定句柄地址。
  • 使用直接指针,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,速度更快。
垃圾收集器

  研究垃圾收集(GC)的原因是,当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,就需要对这些“自动化”的垃圾收集技术实施必要的监控和调节。

对象能否回收判断
引用计数算法

  简单说,引用计数就是,应用对象则计数器加1,当引用失效时,计数器值就减1,当计数器为0就不再被使用。
  主流的Java虚拟机没有选用引用计数算法来管理内存,主要原因是它很难解决对象间相互循环引用的问题。

可达性分析算法

  这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连接时,则证明该对象是不可用的。
Java中可作为GC Roots的对象包括:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即Native方法)引用的对象。
引用划分

  Java将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference)和虚引用(Phantom Reference)。

  • 强引用。只要强引用在,垃圾收集器就不会回收掉被引用对象。
  • 软引用。用SoftReference来实现软引用。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。
  • 弱引用。用WeakReference来实现弱引用。在进行垃圾收集时,被回收。
  • 虚引用。用PhantomReference来实现虚引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
对象的最后希望

  即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并进行一次筛选,筛选的条件是该对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”。
  如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。所谓的“执行”是指虚拟机会触发这个方法,但并不会承诺等待它运行结束,这是因为,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将可能会导致F-Queue对列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象跳脱死亡命运的最后一次机会,如果想拯救自己,只要在finalize()方法中重新建立关联就好,如果这个时候没有拯救,基本上就真被回收了。
  注意任何一个对象的finalize()方法都只会被系统自动调用一次,而且该方法没有任何好处,建议忘记它的存在,不要使用它。

回收方法区

  永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
  回收废弃常量与回收Java堆中的对象非常相似,当常量池中的常量没有对象引用它的时候,在必要的情况下,它就可以被回收。
判定一个类是否是“无用类”需要同时满足下面3个条件:

  • 该类所有的实例都已经被回收。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

“无用类”仅仅可以被回收,而不是必然被回收。

垃圾收集算法
标记-消除算法

  过程:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
  两点不足:效率不高和清除后产生大量不连续内存碎片。

复制算法

  它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法进行内存回收,不会出现内存碎片,而且运行高效,但其将内存缩小为原来的一半。
  注意现在的商业虚拟机都采用这种算法来回收新生代。
  经过研究表明,新生代中的对象98%是“朝生夕死”的,所以出现了将内存空间划分为一块较大的Eden空间和两块较小的Survivor空间的分配方法。每次使用Eden和其中一块Suvivor。当回收时,将Eden空间和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。

标记-整理算法

  当对象存活率高时(例如老年代),就不适合使用“复制算法”。
  “标记-整理算法”的标记过程与“标记-清除算法”一样,但后续步骤不是直接对可回收对象就行清理,而是让所有存活的对象向一端移动,然后直接清理掉端边界意外的内存。

分代收集算法

  一般是把Java堆分为新生代和老年代,因为新生代和老年代的对象存活周期不同,所以使用不同的收集算法。

HotSpot算法实现
  1. 枚举根节点
      可达性分析对执行时间的敏感不紧体现在查找GC Roots的节点的数据量上,还体现在GC停滞上,因为这项分析必须在一个能确保一致性的快照中进行,以保证不会出现在分析过程中对象引用关系还在不断变化的情况。所以这会导致GC进行时必须停滞所有的Java执行线程
      为了减少停滞时间,HotSpot的实现中,使用了一组称为OopMap的数据结构来存放对象引用,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置(安全点)记录下栈和寄存器中哪些位置是引用。
  2. 安全点
      可能导致引用关系变化的指令很多,如果为每一条指令都生成对应的OopMap,那将会需要很大的额外空间,这样GC的空间成本过高。所以HotSpot确实没有为每一条指令都生成OopMap,只是在“特定的位置”记录下这些信息,这些位置成为安全点。
      安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。
      当GC发生时,如何让所有的线程都“跑”到最近的安全点上再停顿下来,有两种方案:抢断式中断和主动性中断。
      几乎没有虚拟机实现抢断式中断。
      主动式中断的思想是当GC需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
  3. 安全区域
      安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点,但当程序没有分配到CPU“不执行”的时候,其就无法响应JVM的中断请求,运行到安全的地方去中断挂起,对于这种情况就需要“安全区域”来解决问题。
      安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
      当线程执行到“安全区域”中的代码时,首先标识自己已经进入了“安全区域”,当在这段时间里JVM要发起GC时,就不用管标识自己为“安全区域”的线程了,在线程要来开“安全区域”时,它要检查系统是否已经完成了根节点枚举,如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开“安全区域”的信号为止。
Java内存分配策略

  对象的内存分配,大方向上讲,就是在堆上分配(也可能经过JIT编辑后被拆散为变量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一中年垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
1. 对象优先在Eden分配
  当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。
  Minor GC和Full GC的区别:
  新生代GC(Minor GC):指发生在新生代的垃圾收集动作,新生代对象存活周期短,所以Minor GC非常频繁,一般回收速度也比较快。
  老年代GC(Major GC|Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC,回收速度比Minor GC慢很多。
2. 大对象直接进入老年代(参数控制)
  目的:避免在Eden区及两个Survivor区之间发生大量的内存复制。
大对象指需要大量连续内存空间的Java对象,最典型的就是很长的字符串以及数组。
  大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象,容易导致内存还有很多空间时,就提前触发垃圾收集以获取足够的连续的空间来“安置”它们。
3. 长期存活的对象将进入老年代
  虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设置为1,对象在Survivor区中每“熬过”一次Minor GC,年龄就增长1岁,当它的年龄增加到一定程度,就将会被晋升到老年代中。
4. 动态对象年龄判断
  为了更好适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到一定程度才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
5. 空间分配担保
  在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的,如果小于,或者HandlerPromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值