内存的划分

本文所谈的内存是指的运行时数据区域,分为:

  • 共享的数据区:
    • 堆(Heap)
    • 方法区(Method Area)
  • 线程隔离的数据区:
    • 虚拟机栈(VM Stack)
    • 本地方法栈(Native Method Stack)
    • 程序计数器(Program Counter Register)

Java线程栈的内存管理(隔离内存)

线程隔离的性质意味着线程和栈是关联的,每个线程在创建时会创建自己的栈:
  • 这个线程栈中存放栈帧,栈帧本身也是一个栈,它类似一个方法的栈,学过汇编的同学都知道一个方法的执行实际上是一个进栈和出栈的过程(当然还有在处理器中的计算,但于此我们只考虑数据读取与存储的问题),线程栈定的栈帧也就是活动栈帧,也就是当前执行的方法,它会在最后一次出栈返回返回值,作为下一个栈帧执行的一个参数。
  • 我们可以举一个简单的例子:
    • 最开始线程栈中只有一个主函数,它作为栈帧放在线程栈顶端。
    • 我们调用了max(a,b)函数,也就是将a,b两个变量压到方法栈max中,然后作为一个栈帧进线程栈,执行完后,将较大值出栈,压入主函数的方法栈,且max所在的栈帧出栈,main函数重新回到线程栈顶,继续执行。
  • 栈式内存分配
    • 栈式内存的典型就是局部变量,声明时入栈(大小确定),生命周期结束出栈,不能再被使用,入栈和出栈决定了它的声明周期。
    • 栈式内存分配虽然每个变量的大小是固定的,但是在编译器能确定,所以是静态分配的,存放在栈区
线程的存在,是为了实现多线程,也导致了线程的调度现场的恢复
  • JVM的多线程是通过线程之间的轮流切换分配处理器执行时间的方式来实现的。
  • 每个线程都需要一个程序计数器,记录当前虚拟机字节码指令的地址,方便在线程获取到执行时间时恢复现场
  • 程序计数器只对Java方法有意义,对native方法无作用

堆的内存管理(共享内存)

  • 堆是存储Java对象实例的内存区域(引用存放在栈区),每个Java对象都是这个对象的类的一个副本,它会复制包括继承自它父类的所有非静态属性。
  • 堆是被所有Java线程所共有的,所以在多线程情景下,这部分内存时需要同步的
  • 堆内存分配的优势是能够动态分配内存大小,但是缺点也很明显,就是存取速度较慢
  • 堆内存的回收是靠垃圾回收机制保证,是一个或多个守护线程(daemon thread)完成的。(回收策略会在后面详谈)

方法区(内存回收中的永久代PermGen)

  • 和堆一样,是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 内存的回收也靠GC来保证,但是它不会被频繁回收(回收策略会详细说明,在此只阐述概念)

本地方法栈

顾名思义,本地方法栈为虚拟机使用到的Native方法服务

Java主要采用的就是堆式栈式两种分配策略相互补充

内存的分配与回收

静态内存的分配和回收

  • 在Java中静态内存分配是指在Java被编译时就已经能够确定需要的内存空间,当程序被加载时系统把内存一次性地分配给它
  • 我们知道静态内存是在Java栈上分配的,当这个方法结束时,对应的栈帧也就撤销了,所以分配的内存也就被回收了。

动态内存分配与回收

分配就是在堆上动态分配大小,所以我们将重心放在回收上讨论,我们要关注的问题无非以下几个:

  • 回收垃圾的时机和频率
  • 正确检测出垃圾
  • 能够释放垃圾对象占用的内存空间
垃圾检测算法

在垃圾检测中无论是哪种算法,都用到了引用,引用在垃圾的检测中是用来判断的主要因素,首先我们要说明一下引用的概念:

  • 引用: 如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用:
    • 强引用(Strong Reference):只要强引用(引用在栈中,遵循栈的回收规则)存在,垃圾收集器永远不会回收掉被引用的对象
    • 软引用(Soft Reference):对象不会被立即回收,除非JVM需要内存,为了防止溢出,才会收集掉软引用的对象
    • 弱引用(Weak Reference):被弱引用关联的对象的只能活到下一次垃圾收集之前。无论JVM的内存是否足够,都会被回收
    • 虚引用(Phantom Reference):这种引用完全不会对所引用的对象的生存时间造成影响,而且也无法通过需要虚引用取得一个对象实例。为一个对象设置虚引用关联的目的就是能在这个对象被收集器回收时收到一个系统通知
  • 以上引用方式JDK的现行版本都提供了接口和实现
  • 引用计数算法
    • 概念:给对象中添加一个引用计数器,每当有一个地方引用它时,技术器值就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象不可能再被使用的。
    • 优点:实现简单,判定效率很高。
    • 缺点:很难解决相互循环引用的情况(循环引用,两个对象互相+1,导致计数器永远不为0,造成内存泄漏)
  • 可达性算法(Java采用的该算法)
    • 通过可达性分析(Reachability Analysis)来判断对象是否存活的
    • 所谓可达性就是当前对象能够被根对象集合(GC Roots)到达,存在一条到达根对象的引用链
    • 根对象集合(GC Roots):
      • 在方法中的局部变量区的对象的引用
      • 在Java操作栈中的对象引用(递归)
      • 在常量池(位于方法区中)中的对象引用
      • 在本地方法中持有的对象引用
      • 类的Class对象
垃圾收集算法
  • 标记-清除(Mark Sweep)算法
    • 首先标记出所有需要回收的对象(利用检测算法),然后对标记过的对象统一进行回收
    • 是收集算法的基础
    • 但是具有明显的不足:
      • 效率问题:标记清除两个过程都是全局扫描,效率都不高
      • 空间问题(主要问题):标记清楚之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中要分配较大的对象时,无法找到足够的连续内存而不得不提前触发一次垃圾回收动作(造成空间的浪费)
  • 复制(copying)算法(解决效率问题):
    • 将内存划分成大小相等的两块,每次只使用其中一块。
    • 每次一块的内存用完了,就将还存活的对象复制到另一块上,然后将已使用的一次清理掉
    • 每次都是对整个半区进行回收(移动堆顶指针即可),无需考虑内存碎片的问题(因为是顺序分配的),实现简单,而且高效。
    • 但是内存缩小为原来的一半,代价比较大
    • 在我们后面要详细介绍的分代算法中,对于回收比较频繁,而且存活率较低的新生代就主要采取的复制算法
      • 因为新生代具有存活率低的特性,所以每次回收中存活的对象的比例都比较低,所以完全不需要按照1:1的比例进行划分,而是将堆区划分成了一块较大的Eden区和两块较小的Survivor空间,每次使用Eden区和其中一块Survivor区,然后每次回收就采用复制算法进行。
      • 但是小概率事件不代表不会发生,所以当Survivor空间不够时,我们依赖老年代(Old)进行分担担保
      • 如果Survivor空间没有足够额空间存放上一次新生代收集下来的存活对象时,这些对象直接通过分担担保机制进入老年代
  • 标记-整理(Mark-Compact)算法
    • 老年代的对象存活率较高,如果使用复制算法,会导致较多的数据复制,造成算法效率的退化,而且复制算法需要分配担保机制来保证在高存活率下的正确性
    • 标记部分和标记清除算法没区别
    • 而是在统一回收时,先将存活对象向一端移动(解决空间问题),然后清理边界以外的内存
  • 基于分代的垃圾收集(Generational Collection)算法(Java使用)
    • 设计思路:
      • 把对象按照寿命长短来分组,分为年轻代老年代
      • 新创建的对象被分在年轻代,如果对象经过几次回收仍然存活,那么再把这个对象划分到老年代
      • 老年代的收集频度不像年轻代那么频繁,这样就减少了每次垃圾收集时所要扫描的对象的数量,从而提高了垃圾回收效率
    • JVM对于整个堆的划分:
      • Young区:Young区又分为Eden和两个Survivor区,采用复制算法回收,当Eden区满后触发minor GC 将Eden仍然存活的对象和当前Survivor区中存活的对象复制到另一个Survivor区中。
      • Old区: 采用标记-整理算法。
        • 存放的是Young区的Survivor满后触发minor GC 后仍然存活的对象,当Eden区满后会将对象存到Survivor区中
        • 如果Survivor区仍然存不下这些对象,GC 收集器会将这些对象收集到Old区
        • 在Survivor区的对象足够老,也直接存到Old区。如果Old区也满了,将会触发Full GC,回收整个堆的内存
      • Perm区:就是方法区,主要存放的是类额Class对象,如果一个类频繁加载可能会导致Perm区满,也是在Full GC 触发的。
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐