java原理实现: 从虚拟机到源码
Sun公司与其他组织发布了许多可以运行在各种不同平台上的虚拟机, 这些虚拟机都可以编译和执行同一种平台无关的字节码, 从而实现了"一次编写, 到处运行".
了解Java内存
Java内存是由虚拟机自动管理的, 当时也会发生内存溢出异常. 为了在发生内存溢出时不至于束手无策, 还是需要了解 Java 怎么管理内存的.
a. Java内存划分
Java内存划分按照各自的用途以及创建和销毁的时间. 优点区域随着虚拟机进程的启动而存在, 有的依赖用户线程的启动和结束而建立和销毁.
b. 跟随线程的区域
程序计数器
程序计数器可以看做是当前线程执行的字节码的行号指示器. 它是通过改变计数器的值来选择下一条需要执行的字节码指令. 是程序能够按照逻辑执行各种操作的关键.
每个线程都有自己的程序计数器, 同一时刻一个处理器只会执行一个独立的程序计数器. 各条线程之间的计数器互不影响, 独立存储. 可以看做是 "线程私有" 的内存.
Java虚拟机栈
每个线程拥有独立的Java虚拟机栈. 它的生命周期与线程相同.
虚拟机栈描述的是Java方法 执行的内存模型. 每个方法在执行的时候都会创建一个栈帧( Stack Frame 栈框架), 存储了表: 局部变量表 (详见书籍8.2章), 操作数栈(记录操作次数), 动态链接(指向运行时常量池, 取出该栈帧所属方法的部分常量), 方法出口等信息. 在虚拟机运行的时候用到.
局部变量表:
存放了在编译期可知的各种基本数据类型(虚拟机在编译代码时就确定了方法变量的数量和类型等), 包括8种基本数据类型和对象引用( Reference 类型, 可能是一个指针,指向对象起始地址, 或是指向一个代表对象的句柄或其他与此对象相关的地址位置) 和 returnAddress 类型(指向一条字节码指令的地址).
64位的 long 和 double 类型的数据占用了2个局部变量空间(Slot, 可看作空间单位), 其他类型占1个Slot.
所需的空间在编译期分配完成,
当进入一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的. 运行期间也不会改变. 因为这里 跟方法是否执行无关, 仅记录了方法的特征. 每当方法需要的时候就从局部变量表中读取.
编译期发生了哪些事(后面会有详细解释): Java虚拟机按照规范, 把代码按照固定的 Class 文件格式解析成二进制文件. Class文件中描述了类, 方法, 属性, 接口等各种信息. 需要提前加载的常量会提前加载, 整个过程包含了验证, 分配内存等操作.
栈深: 如果创建时线程请求的栈深度大于虚拟机允许的深度, 将抛出 StackOverflowError 异常; 大部分虚拟机允许动态扩展, 扩展时如果无法申请到足够的内存, 就会抛出 OutOfMemoryError 异常.
本地方法栈
与虚拟机栈非常的相似, 是虚拟机的一部分. 只不过是为虚拟机的Native方法服务. 虚拟机栈是为了执行Java方法(已翻译成字节码)服务.
同样会抛出 StackOverflowError 和 OutOfMemoryError 异常.
HotSpot直接把本地方法栈和虚拟机栈合二为一.
c. 随虚拟机启动的区域
Java堆
只用来存放对象实例. 因此一般是所有内存中最大的一块. 它被所有线程共享. 几乎所有的对象实例都要在这里分配内存. 垃圾收集器也主要管理这一块. 因此也成 "GC堆" (Garbage Collected Heap). 还可细分为新生代和老年代. 具体在后面解释
内存分配:
优化内存分配的目的是为了更好的回收内存, 或者更快的分配内存.
Java堆可以是不连续的, 容量对大部分虚拟机来说都是按照可动态扩展来实现. 当堆中没有足够内存完成实例分配, 并且也无法再扩展时, 将会抛出 OutOfMemoryError 异常.
方法区(非堆)
用于存储已被虚拟机加载的类的信息, 常量, 静态变量, 即时编译器编译后的代码等数据. 虽然Java虚拟机规范描述为属于堆的一个逻辑部分, 但是有个别名叫 Non-Heap (非堆).
在HotSpot中, 使用永久代的GC方法管理方法区. 实际并不等价于永久代. 而且较其他虚拟机更容易产生内存溢出问题.
这个区域的垃圾回收主要针对常量池的回收和类型的卸载. 回收效果难以令人满意, 尤其是类型卸载的条件相当苛刻.
运行时常量池 :
方法区的一部分. 编译成的Class文件中有一项信息就是常量池 (Constant Pool Table). 用于存放编译期生成的各种字面量和符号引用. 这部分内容在类加载后会进入方法区的运行时常量池中存放.
Java并不要求常量一定只有编译期才能产生(Class文件常量池的内容), 运行期间也可能将新的常量放入常量池中. 比如 Java 代码中字符串的连接.
当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常.
d. 直接内存
直接内存 (Direct Memory) 不属于虚拟机运行时数据区的一部分, 也不是Java虚拟机规范中定义的内存区域. 但是也被频繁使用, 同样会导致 OutOfMemoryError 异常.
JDK1.4加入了NIO (New Input/Output) 操作, 引入了一种基于通道 (Channel) 与缓冲区 (Buffer) 的 IO方式. NIO操作需要用到Direct Memory内存, 通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作. 避免了在 Java堆 和 Native堆中来回复制数据.
因为用到内存, 必然受本机内存影响. 如果把内存都分配给虚拟机, 用到直接内存时就会抛出 OutOfMemoryError 异常. 尤其是大量使用 NIO的程序.
虚拟机做了哪些工作?
编译
虚拟机编译器把写好的代码编译为存储字节码的Class文件.
虚拟机不和任何语言绑定, 它只与"Class" 文件 <一种特定的二进制文件格式> 关联. Class文件中包含Java虚拟机的指令集和符号表以及其他辅助信息. 代码中的各种变量,关键字,运算符的语义最终都是由多条字节码命令组合而成. 所以一些Java本身无法有效支持的语言特性不代表字节码本身无法有效支持.
释疑: 什么是Java编译, 编译期, 编译时都做了哪些事情?
执行
虚拟机根据Class文件执行各种指令, 完成所有操作.
n. new 一个对象:
分配内存, 通过指针碰撞, 或者空闲列表. 考虑到线程安全, 一种是对分配内存空间的操作进行同步处理--实际上虚拟机采用CAS(Compare And Swap 比较并操作)和失败重试的方式保证更新的原子性, 另一种是每个线程在java堆中预先分配一小块内存空间, 称为本地内存分配缓冲(Thread Local Allocation Buffer, TLAB). 某个线程需要内存, 就从所属的TLAB上分配. 只有TLAB用完, 并分配新的TLAB时, 才需要同步锁定.
分配完成后, 虚拟机要将分配到的内存空间都初始化为零值(不包括对象头. 如果使用TLAB, 则在TLAB分配时进行). 这一步操作保证了对象的实例字段在java代码中不需要赋初值就直接使用. 程序能访问这些字段的数据类型所对应的零值.
接下来, 虚拟机要对对象进行必要的设置, 这些信息存放在对象头(Object Header)之中. 根据虚拟机当前的状态不同, 对象头会有不同的设置方式.
此时, 对象的创建对虚拟机来说已经完成了. 但是对程序来说所有的字段都还为零, 还要执行<init>方法. 一般来说, 执行new指令之后都会紧接着执行<init>方法(详见.class文件的方法表), 把对象初始化, 这是一个真正的对象才被new出来.
内存管理
虚拟机在运行的同时进行内存管理. 当内存已满, 就会进行GC(Garbage Collection).
虚拟机如何管理内存:
java内存运行时, 程序计数器, 虚拟机栈, 本地方法栈3个区域随线程而生, 随线程而灭. 每一个栈帧中分配多少内存基本上是在类结构确定下来的时候就是已知的. 因此这几个区域的内存分配和回收都具备确定性. 随着方法结束或者线程结束时, 内存自认就跟着回收了. 而Java堆和方法区则不一样. 一个接口的多个实现类需要的内存可能不一样, 一个方法的多个分支需要的内存也不一样. 只有在程序运行期间才能知道会创建哪些对象. 这部分内存的分配和回收都是动态的. GC也主要关注这部分内存.
a. 什么时候引发GC
新生代(Eden)没有足够的空间分配, 虚拟机将发起一次Minor GC(新生代GC, 特性: 非常频繁, 速度快).
大对象(典型: 很长的字符串,数组)的问题: 经常出现大对象容易导致内存还有不少空间时就提前触发GC以获取足够的连续空间. 可以设置大于某个值的对象直接进入老年代, 避免在Eden区和两个Survivor区来回的复制(复制算法).
晋升老年代: 活过多次GC, 或相同年龄的所有对象占用空间很大, 大于等于这个年龄的对象直接进入老年代, 或者大对象.
空间分配担保:
在发起Minor GC之前, 虚拟机会先检查老年代的最大可用的连续空间是否大于新生代的所有对象总空间. 如果成立, 则Minor GC是安全的, 不成立, 则带有风险. 如果允许担保失败, 就会进行尝试一次Minor GC, 否则改为Full GC(老年代GC 比Minor GC慢10倍以上).
分配担保: 新生代内存已满, 需要老年代进行分配担保, 把之前每一次回收晋升到老年代对象容量的平均大小值作为经验值, 与老年代的剩余空间进行比较, 决定是否进行Full GC来让老年代腾出更多空间. 如果担保失败, 只能在失败后发起一次Full GC.
b. 判断对象是否死亡: 可达性分析算法和引用计数法
1. 引用计数法:
原理: 给对象添加一个引用计数器, 每当有一个地方引用它时, 计数器加一, 当引用失效时, 引用就减一. 任何时刻计数器都为0的对象就是不可使用的.
优点: 实现简单, 判定效率高.
缺点: 很难解决对象之间的循环引用的问题.(两个对象相互引用)
- 可达性分析算法: