前言
内存对于终端设备而言是一个比较稀缺珍贵的存在,所以在开发过程中,手机内存就变成了影响用户使用体验的一个非常重要的东西。如果开发中不遵循开发规范,合理使用内存空间,则会带来很不好的使用体验。所以作为一名优秀的开发者,代码必须尊重内存。
JVM内存区(分配之分区)
首先内存是分为物理内存和虚拟内存,但是虚拟内存是基于物理内存的一个概念性分配规则。
物理内存就是我们通常所说的RAM,比如说一个手机内存是4G,6G,8G。这里所说的内存条就是我们说的物理内存。
虚拟内存则更倾向于对进程调度而言的,比如说我们手机在启动一个进程时默认分配给一个进程的内存空间是固定的,显然不会把物理内存大小都分配给进程使用。分配给进程的是进程内线程共享的内存。剩余的内存量则是进程间的共享内存。共享内存则有cpu自行调配使用。
JVM将内存分为六个区
JVM是按照运行时数据的存储结构来划分内存结构的,JVM在运行Java程序时,将他们划分成几种不同格式的数据,分别存储在不同的区域。主要是分为六个区:
PC寄存器(程序计数器)
栈
堆
方法区
本地方法区
运行时常量区
PC寄存器:用于保存线程执行节点,属于线程私有的一种数据结构。
栈:创建线程时创建,用来存储栈帧,也是线程私有。java程序中的方法执行时,会创建一个栈帧,用于存储临时数据 、中间结果、局部变量表、操作数栈、动态链接、方法出口等信息。溢出会报StackOverflowError。线程结束自动回收。
堆:所有线程共享,用于存储对象。堆空间不够,同时无法申请足够内存时,会报OutOfMemoryError。所以在处理一些较大对象比如bitmap之类的时,容易出现OOM。
方法区:各个线程共享,存储静态变量、运行时常量池等信息。
运行时常量池:每个类一个,从class常量池中迁移过来,程序中使用的常量值。
本地方法栈:支持native方法,如在java中调用C/C++。
其中堆栈 最重要,所以在日常开发中,我们经常看到栈溢出和堆益处的日志。但是说到底这是从使用角度划分出的六个区域,从上帝视角来看,就是以 “被存储数据的特点结合不同的数据结构和算法”来划分的。
这里额外说一句就是,内存是物理存在的,因为针对不同数据使用不同算法,才有了区域的划分,jvm这么做,是肯定是为了存储+读取更高效。比如说栈结构,存在此处的是线程相关的,这里的存储规则就是遵循栈算法先进先出原则,但这种存储条件是,在创建时要计算出固定内存进行存储。但堆结构的特点是,动态大小分配原则。他们作用不同所以存储对象也有所差别。这里可以看看相关文章的讲解。十分受益。通常的内存分配策略
JVM内存区(回收之标记清理)
大家都知道内存是一个比较稀缺资源,重在分配和回收。所以问题是,有效的标记才能让内存能及时有效的被释放然后供他处所用来提高效率。
标记一个对象是否在被引用,先来回顾一下对象被引用的几种类型(JDK1.2+)。
强引用
软引用 (SoftReference,内存严重不足时被GC回收)
弱引用 (WeakReference,GC直接回收)
虚引用 (太弱了基本无用)
如何来标记内存块是否可以回收,就是来判该内存存储的对象,是否可以被回收。那么对象什么时候可以被回收呢?那当然是对象再也没有任何其他对象引用的时候,就可以被看作是“无用对象”即可回收了。
所以,如何标记对象是否还在被引用?
大致有以下两种标记方法:
引用计数法
可达法
引用计数法,大致就是对象被引用一次,引用次数累计加1,这种标记方法比较简单,但引用计数法弊端是:互相引用是死扣,计数器无法清零则对象永远无法被回收。
可达法,可达法就是由GC Roots 出发以此向下标记,类似一个树形结构。
这种方式比较高效,也可以解决互相引用的导致计数器无法清零的弊端。
对象标记过程已清晰后,回收时是如何回收的?
回收算法
很多文章整理内容会整理为 标记/清除算法,标记/整理算法。但是对待这个问题要分开理性对待,标记过程就以上两种,但是回收内存时,回收过程对内存的整理,也是一个灵活复杂的过程。
首先大概有
标记/清理算法(常用于老生代)
标记/整理算法(老生代)
标记/复制算法(新生代)
分代回收算法 (这种属于分配策略)
先说分代回收算法,内存区分代,分为 “新生代”和“老生代”。这种分配策略也是为了更高效的回收。新生代存放生命周期比较短的对象存储,利用率高且生命周期短。老生代存放较大对象且生命周期比较久的对象。
当然,针对新生代区和老生代区的内存,各自回收算法,利用各区的特点,采用不同的回收算法。
这几种回收算法,都是在GC线程触发后,进行的回收操作:
标记/清理算法:
这种算法就是,从GCRoot开始检索,探索“活对象”后标记,对无用对象直接进行回收。但是此算法内存容易出现“不连续”,易出现片段化内存。比较常用于 老生代内存区
标记/整理算法:
这种算法标记过程一致,但回收无用对象时,会对内存区域进行整理,把“活对象”复制到内存头部,连续存储,则剩余内存区直接回收。这种会使内存避免出现片段化情况,会整理出大块内存出来,但是回收过程需要复制活对象地址进行整理,效率比较低。比较常用于 老生代内存区
标记/复制算法:
这种算法特定使用在现在“新生代内存区”的一种更精细化的内存区分区中,新生代分区中也会再分为“新生0代”,“新生1代”。依次存放新旧程度的对象,在整理时,新生0代区内存,直接复制到新生1代内存区中,来优化内存管理。
ps:还有一个标记/压缩法,待矫正。
JVM与Dalvik、ART
由于在面试过程中面试官经常问到JVM与安卓虚拟机的区别,所以导致很多人误解以为Dalvik是对JVM的一种改造和包装。实际并不是这样。他们是并列关系。用于不同的环境使用。
JVM是线程概念的虚拟机,对于JVM来说,执行单元是线程。但是Dalvik则是推出的进程的概念。一个进程是一个独立的虚拟机,这样有效的保护了进程间不受影响。
那么为什么总把他们相提并论呢?原因可能有两个,一个是,一开始Android主要开发语言是Java,所以会容易比较。第二就是,通过Dalvik透漏出的一些设计,包括内存管理回收的方案,很大程度上是借鉴了JVM的。也容易放在一起比较,包括现在最新的ART,也是采用了新生代和老生代的分配策略。
那么简单再了解一下Dalvik与ART
Dalvik:
Dalvik是Google特定给Android系统设计的虚拟机,是不可以直接运行.class文件,是运行.dex文件的。后面为了提高运行速度,把.dex优化为odex文件。大家看到odex不要陌生,它就是虚拟机直接运行结构。是dex解压优化后的一个结构。
ART:
ART是在Dalvik基础上的一种改良。他们详细的区别请务必看完此篇介绍
在日常开发中,我们能做些什么?
在了解一部分内存管理机制后,对我们日常开发有什么用吗?我们需要做些什么吗?
首先,内存是一个比较稀缺的资源,如果能高效的利用它,用户体验上会有很大提升。
有关内存的两个问题 内存溢出 与 内存泄漏
内存溢出:
内存溢出就是我们常见的OOM,内存犹豫剩余空间不足分配,导致系统发出的错误,直接阻塞一切操作。这个就是一个比较严重的问题了,说明系统在通过GC回收后,还是不够当前要进行的内存分配。
内存泄漏:
内存泄漏是指,某些对象在完成它的生命周期结束后,无法被有效的回收,在系统内还被标记为 活对象 ,导致无法及时被回收,占用内存,时间久了会导致 内存溢出。
所以我们能做些什么?
针对 内存溢出,一般情况下发生情况就是 创建大对象 ,一般是数据对象,图片对象。针对数据对象,我们需采取缓存或者适合的存储结构。 针对图片对象,我们可以采用cdn,压缩,等优化操作对图片对象进行处理。
针对 内存泄漏:
内存泄漏发生的根本原因是,生命周期短的对象被生命周期长的对象所引用,然后引用未及时清除,导致生命周期短的对象在回收时,还被判定为“活对象”导致无法回收。所以分析内存泄漏一定要注意长生命周期的组件使用。
Android中常见的内存泄漏:
非静态内部类导致的内存泄露,比如Handler,解决方法是将内部类写成静态内部类,在静态内部类中使用软引用/弱引用持有外部类的实例
IO操作后,没有关闭文件导致的内存泄露,比如Cursor、FileInputStream、FileOutputStream使用完后没有关闭
Context导致的内存泄漏,尤其单例模式,所以推荐使用application context
服务类组件,BroadcastReceiver,Service,一定要注意unregister
自定义view (曾经遇到过一个特例,实在是回忆不起来了,大家自定义view的时候要注意些)
一些特殊对象,file,bitmap,thread用完要recycle及时释放内存
线程类一定要注意它的生命周期是否与当前context同生命周期长度
由于时间久远,引用其他作者内容未记录出处,向所有原创作者致敬。谢谢看到最后。