内存管理是计算机编程中的一个重要问题, 一般来说, 内存管理主要包括内存分配和内存回收 两个部分. 不同的编程语言有不同的内存管理机制, 本文在对比 C++ 和 Java 语言内存管理机制的不同的基础上, 浅析 java 中的内存分配和内存回收机制, 包括 java 对象初始化及其内存分配, 内存回收方法及其注意事项等......
java 与 C++ 内存管理机制对比
在 C++ 中, 所有的对象都会被销毁, 局部对象的销毁发生在以右花括号为界的对象作用域的末尾处, 而程序猿 new 出来的对象则应该主动调用 delete 操作符从而调用析构函数去回收对象占用的内存. 但是 C++ 这种直接操作内存的方式存在很大内存泄露风险, 而且人为管理内存复杂且困难.
在 java 中, 内存管理由 JVM 完全负责, java 中的 "垃圾回收器" 负责自动回收无用对象占据的内存资源, 这样可以大大减少程序猿在内存管理上花费的时间, 可以更集中于业务逻辑和具体功能实现; 但这并不是说 java 有了垃圾回收器程序猿就可以高枕无忧, 将内存管理抛之脑外了! 一方面, 实际上 java 中还存在垃圾回收器没法回收以某种 "特殊方式" 分配的内存的情况(这种特殊方式我们将在下文中进行详细描述); 另一方面, java 的垃圾回收是不能保证一定发生的, 除非 JVM 面临内存耗尽的情况. 所以 java 中部分对象内存还是需要程序猿手动进行释放, 合理地对部分对象进行管理可以减少内存占用与资源消耗.
java 内存分配
java 程序执行过程
1, 首先 Java 源代码文件 (.java 后缀) 会被 Java 编译器编译为字节码文件(.class 后缀), 然后由 JVM 中的类加载器加载各个类的字节码文件, 加载完毕之后, 交由 JVM 执行引擎执行(执行过程还包括将字节码编译成机器码),JVM 执行引擎在执行字节码时首先会扫描四趟 class 文件来保证定义的类型的安全性, 再检查空引用, 数据越界, 自动垃圾收集等. 在整个程序执行过程中, JVM 会用一段空间来存储程序执行期间需要用到的数据和相关信息, 这段空间一般被称作为 Runtime Data Area(运行时数据区), 也就是我们常说的 JVM 内存
2, 类加载器分为启动类加载器 (不继承 classLoader, 属于虚拟机的一部分; 负责加载原生代码实现的 Java 核心库, 包括加载 JAVA_HOME 中 jre/lib/rt.jar 里所有的 class); 扩展类加载器(负责在 JVM 中扩展库目录中去寻找加载 Java 扩展库, 包括 JAVA_HOME 中 jre/lib/ext/xx.jar 或 - Djava.ext.dirs 指定目录下的 jar 包); 应用程序类加载器(ClassLoader.getSystemClassLoader() 负责加载 Java 类路径 classpath 中的类)
1, 类加载机制的流程: 包括了加载, 连接(验证, 准备, 解析), 初始化五个阶段
加载: 查找装载二进制文件, 通过一个类的全限定名获取类的二进制字节流, 并将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构; 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象, 作为对方法区中这些数据的访问入口.
验证: 为了确保 Class 文件中的字节流包含的信息符合当前虚拟机的要求, 完成以下四个阶段的验证: 文件格式的验证, 元数据的验证, 字节码验证和符号引用验证.
准备: 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段, 这些内存都将在方法区中分配
解析: 解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程
初始化: 初始化阶段是根据程序员通过程序指定的主观计划去初始化类变量和其他资源, 也就是执行类构造器 () 方法的过程
现代硬件内存架构
1, 一个有两个或者多个 CPU 的现代计算机上同时运行多个线程是可能的, 如果你的 Java 程序是多线程的, 在你的 Java 程序中每个 CPU 上一个线程可能同时 (并发) 执行
2,CPU 在寄存器上的执行操作速度稍微大于 CPU 缓存层的执行速度, 远大于在主存上的执行速度
3,Java 内存模型中的堆栈分布在硬件内存结构中的 CPU 寄存器, CPU 缓存层, CPU 主存中, 大部分分布在主存中
java 内存模型划分
一般来讲, 我们将 java 内存划分为以下几个区域, 如图:
GC 备注:
1, 年轻对象存放在年轻代, 采用 Minor GC(指从年轻代空间 (包括 Eden 和 Survivor 区域) 回收内存); 长期存活的年老对象以及大对象直接存放在年老代, 采用 Full GC(Full GC == Major GC 指的是对老年代 / 永久代的 stop the world 的 GC), 回收速度慢; JVM 维护一个对象的年龄来进行对象的内存区域转移, 从 Eden-Survivor - 老年代
2, 新生代包括一个 Eden 区, 两个 survivor 的 from 和 to 区(8:1:1), 负责年轻小对象的回收; Eden 区存放新创建的大量对象, 回收频繁, 所以区域大; Survivor 存放每次垃圾回收后存活的对象
3, 一个对象的成员变量可能随着这个对象自身存放在堆上
4, 一个 Object 的大小计算方法: 一个引用 4byte + 空 Object 本身占据 8byte + 其它数据类型占据自身大小 byte(例如 char 占用 2byte); 然而由于系统分配以 8byte 为单位, 所以每个 Object 占据的大小必须为 8 的倍数, 比如一个空的 Object 应该占据 4+8=12, 也就是说需要占据 16byte
下文中将要提到的内存分配与回收主要是指对象所占据的堆内存的释放与回收.
java 对象创建及初始化
java 对象创建之后, 就会在堆内存拥有自己的一块区域, 接着就是对象的初始化过程. 对象一般通过构造器来进行初始化, 构造器是一种与类名相同的没有返回值的特殊方法; 如果一个类中没有定义构造函数, 则系统会自动生成一个不接受任何参数的默认构造器; 但是如果已经定义一个构造器(无论是否有参数), 编译器就不会再自动创建默认构造器了; 我们可以对构造函数进行多次重载(即传递不同数目或不同顺序的参数列表), 也可以在一个构造器中调用另一个构造器, 但是只能调用一次, 并且必须将构造器放在最起始处, 否则编译器会报错.
那么类成员初始化又是怎么做的呢? 顺序是怎样的呢? java 中所有变量在使用前都应该得到恰当的初始化, 即使是方法的局部变量, 如果不进行初始化就会发生编译错误; 而如果是类的成员变量, 即使你不进行初始化赋值, 系统也是会给与其一个初始值的, 例如 char,int 类型的初始值都是 0, 对象引用不进行初始化则默认为 null.
类成员初始化顺序总结: 先静态后普通再构造, 先父类后子类, 同级看书写顺序
1. 先执行父类静态变量和静态代码块, 再执行子类静态变量和静态代码块
2. 先执行父类普通变量和代码块, 再执行父类构造器(static 方法)
3. 先执行子类普通变量和代码块, 再执行子类构造器(static 方法)
4.static 方法初始化先于普通方法, 静态初始化只有在必要时刻才进行且只初始化一次.
注意: 子类的构造方法, 不管这个构造方法带不带参数, 默认的它都会先去寻找父类的不带参数的构造方法. 如果父类没有不带参数的构造方法, 那么子类必须用 supper 关键子来调用父类带参数的构造方法, 否则编译不能通过.
java 内存回收
垃圾回收器 (4 种收集器) 和 finalize()方法
java 中垃圾回收器可以帮助程序猿自动回收无用对象占据的内存, 但它只负责释放 java 中创建的对象所占据的所有内存, 通过某种创建对象之外的方式为对象分配的内存空间则无法被垃圾回收器回收; 而且垃圾回收本身也有开销, GC 的优先级比较低, 所以如果 JVM 没有面临内存耗尽, 它是不会去浪费资源进行垃圾回收以恢复内存的. 最后我们会发现, 只要程序没有濒临存储空间用完那一刻, 对象占用的空间就总也得不到释放. 我们可以通过代码 System.gc()来主动启动一个垃圾回收器 (虽然 JVM 不会立刻去回收), 在释放 new 分配内存空间之前, 将会通过 finalize() 释放用其他方法分配的内存空间.
1,Serial 收集器: 一个单线程的新生代收集器, 它进行垃圾收集时, 必须暂停其他所有的工作线程, 直到它收集结束. 简单高效
2,Parallel(并行)收集器: JVM 缺省收集器, 其最大的优点是使用多个线程来通过扫描并压缩堆. 串行收集器在 GC 时会停止其他所有工作线程 (stop-the-world),CPU 利用率是最高的, 所以适用于要求高吞吐量(throughput) 的应用, 但停顿时间 (pause time) 会比较长, 所以对 web 应用来说就不适合, 因为这意味着用户等待时间会加长. 而并行收集器可以理解是多线程串行收集, 在串行收集基础上采用多线程方式进行 GC, 很好的弥补了串行收集的不足, 可以大幅缩短停顿时间, 因此对于空间不大的区域(如 young generation), 采用并行收集器停顿时间很短, 回收效率高, 适合高频率执行.
3,CMS 收集器: 基于 "标记 - 清除" 算法实现的, 它使用多线程的算法去扫描老生代堆 (标记) 并对发现的待回收对象进行回收(清除), 容易产生大量内存碎片使得大对象无法创建然后不得不提前触发 full GC.CPU 资源占用过大, 标记之后容易产生浮动垃圾只能留到下一次 GC 处理
4,G1 收集器: G1 收集器是基于 "标记 - 整理" 算法实现的收集器, 也就是说它不会产生空间碎片. G1 是一个针对多处理器大容量内存的服务器端的垃圾收集器, 其目标是在实现高吞吐量的同时, 尽可能的满足垃圾收集暂停时间的要求. 它可以非常精确地控制停顿, 既能让使用者明确指定在一个长度为 M 毫秒的时间片段内, 消耗在垃圾收集上的时间不得超过 N 毫秒, 具备了一些实时 Java(RTSJ)的垃圾收集器的特征. 垃圾收集器
finalize()方法的工作原理是: 一旦垃圾回收器准备好释放对象占用的存储空间, 将首先调用并且只能调用一次该对象的 finalize()方法 (通过代码 System.gc() 实现), 并且在下一次垃圾回收动作发生时, 才会真正回收对象占用的内存. 所以如果我们重载 finalize()方法就能在垃圾回收时刻做一些重要的清理工作或者自救该对象一次 (只要在 finalize() 方法中让该对象重新和引用链上的任何一个对象建立关联即可).finalize()方法用于释放用特殊方式分配的内存空间, 这是因为我们可能在 java 中调用非 java 代码来分配内存, 比如 Android 开发中调用 NDK. 那么, 当我们调用 C 中的 malloc()函数分配了存储空间, 我们就只能用 free()函数来释放这些内存, 这样就需要我们在 finalize()函数中用本地方法调用它.
对象内存状态 && 引用形式及回收时机
java 对象内存状态转换图
如何判断 java 对象需要被回收? GC 判断方法
1, 引用计数, 引用计数法记录着每一个对象被其它对象所持有的引用数, 被引用一次就加一, 引用失效就减一; 引用计数器为 0 则说明该对象不再可用; 当一个对象被回收后, 被该对象所引用的其它对象的引用计数都应该相应减少, 它很难解决对象之间的相互循环引用问题循环引用实例
2, 可达性分析算法: 从 GC Root 对象向下搜索其所走过的路径称为引用链, 当一个对象不再被任何的 GC root 对象引用链相连时说明该对象不再可用, GC root 对象包括四种: 方法区中常量和静态变量引用的对象, 虚拟机栈中变量引用的对象, 本地方法栈中引用的对象; 解决循环引用是因为 GC Root 通常是一组特别管理的指针, 这些指针是 tracing GC 的 trace 的起点. 它们不是对象图里的对象, 对象也不可能引用到这些 "外部" 的指针.
3, 采用引用计数算法的系统只需在每个实例对象创建之初, 通过计数器来记录所有的引用次数即可. 而可达性算法, 则需要再次 GC 时, 遍历整个 GC 根节点来判断是否回收
java 对象的四种引用 1. 强引用 : 创建一个对象并把这个对象直接赋给一个变量, eg :Person person = new Person("sunny"); 不管系统资源有么的紧张, 强引用的对象都绝对不会被回收, 即使他以后不会再用到. 2. 软引用 : 通过 SoftReference 类实现, eg : SoftReference p = new SoftReference(new Person("Rain")); 内存非常紧张的时候会被回收, 其他时候不会被回收, 所以在使用之前要判断是否为 null 从而判断他是否已经被回收了. 3. 弱引用 : 通过 WeakReference 类实现, eg : WeakReference p = new WeakReference(new Person("Rain")); 不管内存是否足够, 系统垃圾回收时必定会回收 4. 虚引用 : 不能单独使用, 主要是用于追踪对象被垃圾回收的状态, 为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知. 通过 PhantomReference 类和引用队列 ReferenceQueue 类联合使用实现
常见垃圾回收算法参考图
(https://yq.aliyun.com/articles/14411)
停止 - 复制算法 这是一种非后台回收算法, 将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块, 内存浪费严重. 它先暂停程序的运行, 然后将所有存活的对象从当前堆复制到另外一个堆, 没被复制的死对象则全部是垃圾, 存活对象被复制到新堆之后全部紧密排列, 就可以直接分配新空间了. 此方法耗费空间且效率低, 适用于存活对象少.
标记 - 清扫算法 同样是非后台回收算法, 该算法从堆栈区和静态域出发, 遍历每一个引用去寻找所有需要回收的对象, 对每个找到需要回收对象都进行标记. 标记结束之后, 开始清理工作, 被标记的对象都会被释放掉, 如果需要连续堆空间, 则还需要对剩下的存货对象进行整理; 否则会产生大量内存碎片
标记 - 整理算法 先标记需要回收的对象, 但是不会直接清理那些可回收的对象, 而是将存活对象向内存区域的一端移动, 然后清理掉端以外的内存. 适用于存活对象多.
分代算法 在新生代中, 每次垃圾收集时都会发现有大量对象死去, 只有少量存活, 因此可选用停止复制算法来完成收集, 而老年代中因为对象存活率高, 没有额外空间对它进行分配担保, 就必须使用标记 - 清除算法或标记 - 整理算法来进行回收.
JVM 性能调优
1,JVM 分配超大堆 (前提是物理机的内存足够大) 来提升服务器的响应速度, 但分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低, 因为一次 Full GC 的时间造成比较长时间的停顿. 控制 Full GC 频率的关键是保证应用中绝大多数对象的生存周期不应太长, 尤其不能产生批量的, 生命周期长的大对象, 这样才能保证老年代的稳定
2, 分配超大堆时, 如果用到了 NIO 机制分配使用了很多的 Direct Memory, 则有可能导致 Direct Memory 的 OutOfMemoryError 异常, 这时可以通过 - XX:MaxDirectMemorySize 参数调整 Direct Memory 的大小
3, 调整线程堆栈, socket 缓冲区, JNI 占用的内存以及虚拟机, GC 消耗的内存
4,"-Xms and -Xmx (or: -XX:InitialHeapSize and -XX:MaxHeapSize)" 参数: 分别指定初始堆和最大堆大小, Xms 一般代表着堆内存的最小值, JVM 在运行时可以动态调整堆内存大小, 如果我们 设置 Xms=Xmx 就相当于设置了一个固定大小的堆内存; 例如:"java -Xms128m -Xmx2g MyApp" 启动一个初始化堆内存为 128M, 最大堆内存为 2G, 名叫 "MyApp" 的 Java 应用程序; 当我们设置 Xmx 最大堆内存不恰当时就很容易发生内存溢出, 这样我们可以通过设置 - XX:+HeapDumpOnOutOfMemoryError 让 JVM 在发生内存溢出时自动生成堆内存快照, 默认保存在 JVM 的启动目录下名为 java_pid.hprof 的文件里, 分析它可以很好地定位到溢出位置
Linux 下面查看 Jvm 性能信息的命令
jstat: 用于查看 Jvm 的堆栈信息, 能够查看 eden,survivor,old,perm 等堆区的的容量, 利用率信息, 对于查看系统是不是有内存泄漏以及参数设置是否合理有不错的意义. 例如'''jstat -gc 12538 5000 -- 即会每 5 秒一次显示进程号为 12538 的 java 进成的 GC 情况'''
jstack: 用来查看 Jvm 当前的线程 dump 的, 可以看到当前 Jvm 里面的线程状况, 对于查找 blocked 线程比较有意义
jmap: 用来查看 Jvm 当前的 heap dump 的, 可以看出当前 Jvm 中各种对象的数量, 所占空间等等; 尤其值得一提的是这个命令可以导出一份 binary heap dump 的 bin 文件, 这个文件能够直接用 Eclipse Memory Anayliser 来分析, 并找出潜在的内存泄漏的地方.
非 jvm 命令 - netstat: 通过这个命令可以看到 Linux 系统当前在各个端口的链接状态, 比如查看数据库连接数等
内存相关问题
内存泄露是指分配出去的内存没有被回收回来, 由于失去了对该内存区域的控制(例如你把它的地址给弄丢了), 因而造成了资源的浪费. Java 中一般不会产生内存泄露, 因为有垃圾回收器自动回收垃圾, 但这也不绝对, Java 堆内也可能发生内存泄露(Memory Leak; 当我们 new 了对象, 并保存了其引用, 但是后面一直没用它, 而垃圾回收器又不会去回收它, 这边会造成内存泄露
内存溢出是指程序所需要的内存超出了系统所能分配的内存 (包括动态扩展) 的上限
符号引用: 符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能无歧义地定位到目标即可. 符号引用与虚拟机实现的内存布局无关, 引用的目标并不一定已经加载到了内存中.
直接引用: 直接引用可以是直接指向目标的指针, 相对偏移量或是一个能间接定位到目标的句柄. 直接引用是与虚拟机实现的内存布局相关的, 同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同. 如果有了直接引用, 那说明引用的目标必定已经存在于内存之中了.
双亲委派模型: 表示类加载器之间的加载顺序从顶至下的层次关系, 加载器之间的父子关系一般都是通过组合来实现, 而不是继承. 可以防止内存中出现多份同样的字节码, 并确保加载顺序
双亲委派模型的工作过程是: 在 loadClass 函数中, 首先会判断该类是否被加载过, 加载过则进行下一步 -- 解析, 否则进行加载; 如果一个类加载器收到了类加载器的请求, 先不会自己尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到顶层的启动类加载器中, 只有当父类加载器反馈自己无法完成这个加载请求(它的搜说范围中没有找到所需的类时, 子加载类才会尝试自己去加载)
静态分派和动态分派: 静态分派发生在编译阶段, 是指依据静态类型 (变量声明时定义的变量类型) 来决定方法的执行版本, 例如方法重载中依据参数的定义类型来定位具体应该执行的方法; 动态分派发生在运行期, 根据变量实例化时的实际类型来决定方法的执行版本, 例如方法重写; 目前的 Java 语言 (JDK1.6) 是一门静态多分派, 动态单分派的语言.
动态分派具体实现 Java 虚拟机是通过在方法区中建立一个虚方法表, 通过使用方法表的索引来代替元数据查找以提高性能. 虚方法表中存放着各个方法的实际入口地址, 如果子类没有覆盖父类的方法, 那么子类的虚方法表里面的地址入口与父类是一致的; 如果重写父类的方法, 那么子类的方法表的地址将会替换为子类实现版本的地址. 方法表是在类加载的连接阶段 (验证, 准备, 解析) 进行初始化, 准备了子类的初始化值后, 虚拟机会把该类的虚方法表也进行初始化.
JDK7 和 8 中内存模型变化: JDK7 中把 String 常量池从永久代移到了堆中, 并通过 intern 方法来保证不在堆中重复创建一个对象; JDK7 开始使用 G1 收集器替代 CMS 收集器. JDK8 使用元空间来替代原来的方法区, 并且提供了字符串去重功能, 也就是 G1 收集器可以识别出堆中那些重复出现的字符串并让他们指向同一个内部 char[]数组, 而不是在堆中存在多份拷贝
来源: https://juejin.im/entry/5b9c5d22f265da0a89301f0a