建议按照顺序阅读
一. JVM 内存结构
从线程共享和线程私有来说划分如下
- 线程私有: 虚拟机栈, 本地方法栈, 程序计数器
- 线程共享: 方法区, 堆.
- 虚拟机栈
虚拟机栈是基于线程的, 哪怕只有一个 main 方法, 也是以线程的方式运行的, 在线程的生命周期中, 参与计算的数据都会频繁的出栈和入栈. 栈的生命周期和线程是相同的.
栈中的每条数据, 就是栈帧. 在每个 Java 方法被调用的时候, 就会创建一个栈帧并入栈, 一旦完成相应的调用就会执行出栈操作. 所有的栈帧出栈后线程也就结束了. 每个栈帧都包含4部分
- 局部变量表
用来存储方法内定义的的局部变量与方法参数.(但是只能存储基本数据类型的变量与引用类型的)
- 操作数栈
操作数栈也常被称为操作栈, 是一个也是一个先进后出的栈. 方法刚开始执行的时候, 操作数栈是空的, 在方法执行的过程中, 会有各种字节码指令往操作数栈中存取数据 - 动态连接
符号引用和直接引用在 运行时 进行解析和链接的过程就是动态链接. (这里重点是运行时) - 返回地址
一个方法的结束有两种方式,正常执行完成, 非正常退出(出现异常), 无论哪种方式退出, 在方法退出后都返回到该方法被调用的位置. 正常退出时, 将调用该方法的指令的下一条指令的地址作为返回值. 而异常退出时, 返回地址需要通过异常表来确定.
-
本地方法栈
本地方法栈是和虚拟机栈非常相似的一个区域, 它服务的对象是 native 方法. -
程序计数器
程序计数器可以看作是当前线程所指向的字节码的行号指示器. -
堆
堆是 JVM 上最大的内存区域, 几乎所有申请的对象都会在这里存储. 以及常说的垃圾回收, 它的操作对象就是堆. 堆中又划分新生代与老年代. 新生代又划分为 Eden 区与 Survivor 区. -
方法区
用于存储已被虚拟机加载的类型信息, 常量, 静态变量, 即时编译器编译后的代码缓存等.
二. 内存栈溢出
java.lang.StackOverflowError
: 如果出现了这个错误, 可能是无限递归造成的.
OutOfMemoryError
: 不断建立线程, JVM 会不断申请栈内存, 当机器没有足够的内存时, 就会出现这个错误.
三. new 一个对象的流程
-
类加载
-
检查加载
看是否能在常量池中定位到一个类的符号引用. 能定位到就检查这个符号引用代表的类是否已被加载, 解析和初始化过. 无法定位或是没有检查到, 就先执行相应的类加载过程. -
分配内存
对象所需内存的大小在类加载完成以后就可以确定(通过 java 对象的元数据信息可以确定对象大小). 内存的分配方式有两种, 指针碰撞与空闲列表. 同时如果满足逃逸分析, 还会存在栈上分配的情况.- 指针碰撞: 如果堆是规整的, 用过的放在一边, 空间的放在另一边, 中间放着一个指针作为分界点的指示器, 那么分配内存时就仅仅把指针向空闲那边移动一段与对象大小相等的距离.
- 空闲列表: 如果堆不是规整的, 也就是说用过的空间和控件的内存相互交错. 这样虚拟机就需要维护一个列表, 记录那些内存是可用的. 在分配的时候从列表中找到一块满足要分配对象大小的空间划分为对象. 并更新列表记录.
分配内存时还会存在线程安全问题.解决方案是 CAS + 重试 和 TLAB.
- TLAB : 将内存分配的动作按照线程划分在不同的空间之中进行. 即每个线程在 Java 堆中预分配一小块私有内存, 只给当前线程使用. 这小块内存就是 TLAB, 本地线程分配缓冲.
- TLAB 让每个线程私有的分配指针, 但存储对象的内存空间还是给所有线程访问的. 只是其他线程无法在这个区域分配对象.
-
内存空间初始化
为对象分配完内存后, 虚拟机需要将分配到的内存空间都初始化为零值, 不包括对象头. 保证了对象实例字段在Java 代码中可以不赋值就直接使用. -
设置
接着虚拟机需要对对象进行一些必要的设置, 例如这个对象是哪个类的实例, 对象头中的信息, GC 年龄分代, 元数据引用. 对象的哈西码 等等. -
对象初始化
到这一步, 从虚拟机的角度来看, 一个新的对象已经产生了, 但是从 Java 程序的视角来看, 对象的创建才刚刚开始, 所有字段都还是零值. 所以一般来说执行 new 指令之后会接着把对象按照开发者的意愿进行初始化, 执行构造方法. 这样一个真正可用的对象才算是完全产生.
四. 怎么判断对象是否可以被回收
也就是判断对象是否还存活, 一般判断对象存活是两种方式, 引用计数法与可达性分. 用的最多的就是可达性分析.
- 引用计数法
在对象中添加一个引用计数器, 每当有一个地方引用它, 计数器就加一, 当引用失效时, 计数器减一 . 当计数器不为 0 时, 判断该对象存活. 否则判断为死亡(计数器 = 0). 实现简单,高效, 但是无法处理循环引用的问题. - 可达性分析
将一系列的 GC Roots 对象作为起点, 从这些起点开始向下搜索, 搜索所走过的路径成为引用链. 当一个对象到 GC Roots 没有任何引用链相连时, 则证明此对象是不可用的. 在 Java 中可作为 GC Roots 的对象包含以下几种- 虚拟机栈中引用的对象
- 全局静态对象
- 常量引用
- 本地方法栈中 JNI 引用的对象
五. GC 回收算法的种类
常见的 GC 垃圾回收算法有 3 种
-
标记清除
从 GC Roots 开始, 将内存整个遍历一遍, 保留所有可以被 GC Roots 直接或间接引用到的对象, 剩下的对象都被当做垃圾对待并回收. 过程分为两步- 标记阶段
找到内存中所有的 GC Roots 对象, 只要是和 GC Roots 对象直接或者间接相连的标记为存活对象, 否则标记为垃圾对象. - 清除阶段
遍历完后, 将标记为垃圾对象的直接清除.
优点: 实现简单, 不需要对对象进行移动
缺点: 效率不高, 清除后会产生大量不连续的内存碎片, 碎片过多时会导致在程序运行过程中分配大对象时,因无法找到足够的连续内存, 而不得不触发另一次的垃圾回收动作. - 标记阶段
-
复制
将现有的内存空间氛围两块, 每次只使用其中一块, 在垃圾回收时将存活的对象赋值到未被使用的内存块中, 然后再清除之前使用的内存块中的所有对象.
优点: 效率比标记清除要高, 同时不会出现内存碎片.
缺点: 如果对象的存活率较高, 那么复制的操作就会较为频繁. 同时十分浪费内存空间. -
标记整理
标记阶段和标记清除中的标记阶段一样, 在整理阶段是将所有存货的对象向另一端移动, 并顺序排放. 最后清理边界外所有的空间.
优点: 避免了内存碎片, 不会浪费内存,
缺点: 需要对对象进行移动.
六. final, finally, finalize 的区别
- final: 是用来修饰成员变量, 方法, 类的.
- finally: 是作为异常处理的的一部分, 只能用在 try/catch 中. 并附带一个语句块, 表示这段语句最终一定会被执行. 无论有无异常.
- finalize: 通过可达性分析判断不可达的对象, 也不是一定会认定为死亡, 还会再给一次机会. 真正的认定一个对象死亡会有两个标记过程, 一次是没有找到与 GC Roots 的引用链, 会被第一次标记. 随后还会进行一次筛选, 就是判断这个对象有没有重写
Object.finalize()
方法, 如果重写了这个方法并且在方法内与 GC Roots 重新建立了引用链, 那么就有可能不被认定为死亡. 但是虚拟机只会触发这个方法, 并不承诺等待它执行结束. 所以这个方法是不可靠的.
网友评论