java内存基础概念

226 阅读14分钟

一、java内存管理机制

java内存结构与java内存模型的区别

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域:

1、方法区
2、虚拟机栈
3、本地方法栈
4、堆
5、程序计数器

1.程序计数器

这块内存是线程安全的, 在任何一个确定的时刻, 一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令. 因此, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 各条线程之间计数器互不影响, 独立存储, 这块内存区域被称为 线程私有的内存

例如: 如果线程正在执行的是一个java方法, 这个计数器记录的是正在执行的虚拟机字节码指令的地址. 这块内存区域是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域

2.Java虚拟机栈

java虚拟机栈也是线程私有的, 它的生命周期与线程相同.

虚拟机栈描述的java方法执行的内存模型: 每个方法在执行的同时, 都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息. 每个方法从调用直至执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.

java虚拟机规范中队这个区域规定了两种异常情况: 如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出StackOverflowError异常, 如果虚拟机栈可以动态扩展, 如果扩展时无法申请到足够的内存, 就会抛出OOM

3.方法区

方法区与java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据.

3.1运行时常量池

运行时常量池是方法区的一部分, Class文件中除了有类的版本、字段、方法、接口等描述信息外, 还有一项信息是常量池, 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放

二、内存泄漏

开发中遇到很多的OOM, 其实都是由内存泄漏引起的, 随着内存泄漏, 越来越多的内存无法被释放, 再次申请内存时就OOM了, 检测内存很简单, 定时读取内存信息就可以了. 定位内存泄漏, 其实也不是特别方便, 主要是数据的 采集和分析, 还是挺麻烦的, 数据采集会导致进程进入短暂的卡死状态, 如果生产环境使用这个进行数据采集, 体验非常不好, 另外采集到的数据, 通常也都比较大, Matrix对这一块的数据进行了优化, 但是数据也还是比较大, 大概还是有20M左右, 如果上传到后台也是非常消耗流量的.

所以内存泄漏的定位通常是在测试环境, 开发人员自己进行测试. 可以参考LeakCanary, 只记录内存泄漏的Activity或者Fragment, 然后对这个Activity/Fragment再单独的进行分析, 使用Android Studio自带的Profile截取快照配合Mat工具使用.

既然要解决内存泄漏, 必然得知道内存回收机制, 什么情况才会导致内存泄漏, 如何分析一个对象是否会被回收, 如果没有被回收, 又是被什么对象持有导致无法被回收.

2.1 四种引用

1、强引用

  如果一个对象被强引用持有, 则这个对象不会被GC回收

2、软引用

  如果一个对象只被软引用持有, 只要内存空间足够, GC就不会回收这个对象, 只有当内存不足时才会回收这个对象

3、弱引用

  如果一个对象只被弱引用持有, 只要GC触发, 就会回收这个对象

4、虚引用

  PhantomReference.get()方法无论何时返回的都是null, 单独使用虚引用没有什么意义, 只有和引用队列一起使用, 当执行GC时如果一个对象只有虚引用, 就会把这个对象加入到与之关联的ReferenceQueue中

三、GC

如果搞不懂GC, 其实自然也就搞不明白内存泄漏这些, 包括Haha源码如何分析GC Roots也是看不明白的, 关于GC看了一堆文章, 最后发现知乎的R大讲的挺好的. 纯干货, 不搞虚头巴脑的.

java的gc为什么要分代?

Java中什么样的对象才能作为gc root,gc roots有哪些呢?

3.1 GC算法

1、标记-清除算法

2、复制算法

3、标记-整理算法

4、分代收集算法

3.1.1 标记-清除算法

该算法分为两个阶段: 标记清除. 首先标记出所有需要回收的对象, 在标记完成后统一回收所有被标记的对象.

不足:

1、效率问题, 标记和清除两个过程的效率都不高

2、空间问题, 标记清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时, 无法找到足够的连续内存而不得不提前触发另一次GC

3.1.2 复制算法

关于上图的回收流程为, 第一次GC时, 将存活对象复制到右半区域, 然后回收左半区域的内存, 接下来申请内存时从第二个图的左半区域进行申请, 当GC触发时, 再次将存活对象复制到右半区域, 然后回收左半区域的内存

思路: 该算法将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块, 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉. 这样使得每次都是对整个半区进行内存回收, 内存分配时也就不用考虑内存碎片等复杂情况, 只要移动堆顶指针, 按顺序分配内存即可

SurvivorEden: 新生代的对象98%是"朝生夕死"的, 所以并不需要按照1:1的比例来划分内存空间, 而是将内存分为一块较大的Eden空间和两块较小的Survivor空间, 每次使用Eden和其中一块Survivor, 当回收时, 将Eden空间和两块较小的Survivor空间, 每次使用Eden和其中一块Survivor. 当回收时, 将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上, 最后清理掉Eden和刚才用过的Survivor空间.

缺点: 将内存缩小为原来的一半

3.1.3 标记-整理算法

思路: 标记过程仍然与"标记-清除"算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉端边界以外的内存.

3.1.4 分代收集

思路: 根据对象存活周期的不同将内存划分为几块, 一般是把java堆分为新生代和老年代, 这样就可以根据各个年代的特点采用最适当的收集算法. 在新生代中, 每次垃圾收集时都发现有大批对象死去, 只有少量存活, 那就选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集. 而老年代中因为对象存活率高、没有额外空间对它进行分配担保, 就必须使用"标记-清理"或者"标记-整理"算法来进行回收.

3.1.5 分配担保(完全抄书)

分配担保针对的是复制算法, 当Survivor空间不够时, 需要依赖其他内存(这里指老年代)进行分配担保

内存的分配担保就好比我们去银行借款, 如果我们信誉很好, 在98%的情况下都能按时偿还, 于是银行可能会默认我们下一次也能按时偿还贷款, 只需要有一个担保人能保证如果我不能还款时, 可以从他的账户扣钱, 那银行就认为没有风险了. 内存的分配担保也一样, 如果另外一块Survivor空间没有足够空间在放上一次新生代收集下来的存活对象时, 这些对象将直接通过分配担保机制进入老年代.

在发生Minor GC之前, 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象空间, 如果这个条件成立, 那么Minor GC可以确保是安全的. 如果不成功, 那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试进行一次Minor GC, 尽管这次Minor GC是有风险的, 如果小于, 那这时也要改为进行一次Full GC

风险: 新生代使用复制收集算法, 但是为了内存利用率, 只使用其中一个Survivor空间来作为轮换备份, 因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活), 就需要老年代进行分配担保, 把Survivor无法容纳的对象直接进入老年代. 老年代要进行这样的担保, 前提是老年代本身还有容纳这些对象的剩余空间, 一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的, 所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值, 与老年代的剩余空间进行比较, 决定是否进行Full GC来让老年代腾出更多空间.

3.2 GC Roots(完全摘抄R大)

Tracing GC的根本思路就是: 给定一个集合的引用作为根出发, 通过引用关系遍历对象图, 能被遍历到的(可到达的)对象就被判定为存活, 其余对象(也就是没有被遍历到的)就自然被判定为死亡. 注意再注意: tracing GC的本质是通过找出所有活对象来把其余空间认定为"无用", 而不是找出所有死掉的对象并回收它们占用的空间.

3.2.1 可以作为GC roots

所谓"GC roots", 或者说是tracing GC的"根集合", 就是一组必须活跃的引用.

这些引用可能包括:

  • 1、所有java线程当前活跃的栈帧里指向GC堆里的对象的引用, 换句话说, 当前所有正在被调用的方法的引用类型的参数/局部变量/临时值
  • 2、VM的一些静态数据结构里指向GC堆里的对象的引用, 例如说HotSpot VM里的Universe里有很多这样的引用
  • 3、JNI handles, 包括global handles和local handles
  • 4、所有当前被加载的java类
  • 5、java类的引用类型静态变量
  • 6、java类的运行时常量池里的引用类型(String或Class类型)
  • 7、String常量池(StringTable)里的引用 是一组必须活跃的引用, 不是对象
3.2.2 Young GC、Major GC、Full GC
  • Young GC: 只收集young gen的GC
  • Old GC: 只收集old gen的GC, 只有CMS的concurrent collocation是这个模式
  • Full GC: 收集整个堆, 包括young gen、old gen

注意: Major GC通常是跟full gc是等价的, 收集整个GC堆, 但因为HotSpot发展了这么多年, 外界对各种名词的解释已经完全混乱了, 当有人说"major GC"的时候一定要问清楚他想要指的是上面的Full GC还是old GC.

触发条件:

  • young GC: 当young gen中的eden区分配满的时候触发. young GC中有部分存活对象会晋升到old gen, 所以young GC后old gen的占用量通常会有所升高
  • full GC: 当准备要触发一次young GC时, 如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大, 则不会触发young GC而是转为触发Full GC(因为HotSpot VM的GC里, 除了CMS的concurrent collection之外, 其它能收集old gen的GC都会同时收集整个GC堆, 包括young gen, 所以不需要事先触发一次单独的young GC)

四、查看内存相关命令

// 查看Activity、View数量(可以配合检测内存泄漏)
1. adb shell dumpsys meminfo packagename -d
// dvm最大可用内存
2. adb shell "getprop|grep dalvik.vm.heapsize"
// 单个程序限制最大可用内存
3. adb shell "getprop|grep heapgrowthlimit"

4.1 查看Activity、View数量

adb shell dumpsys meminfo packagename -d

Pss : 该进程独占的内存 + 与其他进程共享的内存(按比例分配, 比如与其他3个进程共享9K内存, 则这部分为3K)

Private Dirty: 该进程独享内存

Heap Size: 分配的内存

Heap Alloc: 已使用的内存

Heap Free:空闲内存

4.2 查看分配的最大内存

// dvm最大可用内存
adb shell "getprop|grep dalvik.vm.heapsize"

4.3 单个程序限制最大可用内存

adb shell "getprop|grep heapgrowthlimit"

超过单个程序限制最大内存则OOM, 如果设置了开启largeHeap, 则可以提高到dvm最大内存才OOM

五、hprof文件

检测到发生内存泄漏时, 不管是Matrix还是LeakCanary还是KOOM, 都通过Debug.dumpHprofData采集当前进程内存快照, 并且输出hprof文件, 不同的是LeakCanary全量分析该hprof文件, Matrix分析该文件时进行选择性过滤, 减少了分析生成的文件大小. LeakCanary和Matrix是如何分析hprof文件的?

要分析hprof文件, 必然要知道hprof文件的格式, 否则解析自然无法下手. LeakCanary1.x解析hprof文件使用的是haha库, 2.x之后使用的是shark库分析的hprof文件.

5.1 hprof文件格式

hprof文件使用的基本数据类型为: u1、u2、u4、u8, 分别表示1byte、2byte、4byte、8byte的内容, 由 文件头文件内容 两部分组成.

文件头包含以下信息

长度含义
[u1]*以null结尾的一串字节, 用于表示格式名称及版本, 比如JAVA PROFILE1.0.1(由18个u1字节组成)
u4size of identifiers, 即字符串、对象、堆栈等信息的id的长度(很多record的具体信息需要通过id来查找)
u8时间戳, 1970/1/1以来的毫秒数

文件内存:

文件内容由一系列records组成, 每一个record包含如下信息:

长度含义
u1TAG, 表示record类型
u4TIME, 时间戳, 相对文件头中的时间戳的毫秒数
u4LENGTH, 即BODY的字节长度
u4BODY, 具体内容

Hprof文件定义的TAG有:

enum HprofTag {
    HPROF_TAG_STRING = 0x01,             // 字符串
    HPROF_TAG_LOAD_CLASS = 0x02,         // 类
    HPROF_TAG_UNLOAD_CLASS = 0x03,
    HPROF_TAG_STACK_FRAME = 0x04,        // 栈帧
    HPROF_TAG_STACK_TRACE = 0x05,        // 堆栈
    HPROF_TAG_ALLOC_SITES = 0x06,
    HPROF_TAG_HEAP_SUMMARY = 0x07,
    HPROF_TAG_START_THREAD = 0x0A,
    HPROF_TAG_END_THREAD = 0x0B,
    HPROF_TAG_HEAP_DUMP = 0x0C,          // 堆
    HPROF_TAG_HEAP_DUMP_SEGMENT = 0x1C,
    HPROF_TAG_HEAP_DUMP_END = 0x2C,
    HPROF_TAG_CPU_SAMPLES = 0x0D,
    HPROF_TAG_CONTROL_SETTINGS = 0x0E, 
}
  • 1、字符串信息: 保存所有的字符串, 在解析时可通过索引id引用
  • 2、类的结构信息: 包括类内部的变量布局, 父类的信息等等
  • 3、堆信息: 内存占用与对象引用的详细信息