世间万物皆系于四箭之上
尽管这本书是一本讲述Java的书籍,但是这本书的内容却并不只是针对Java而言。而是针对计算机整个底层的规划,如何通过底层的设计来创造出合理便捷的语言。底层开发人员需要了解上层的应用而设计合理的底层结构,上层开发人员需要连接底层的结构来更好的理解程序的内部逻辑。
程序的运行流程:
编写好的Java文件,首先通过编译器编译为class字节码文件,在这个过程中,虚拟机会对类信息、变量、方法等等信息进行一个排序。之后,运行时,虚拟机会对通过字节码文件的描述,对内存进行划分,安排堆内存、占内存等等,同时通过栈对方法进行一个顺序操作执行。即编译期:先把文件转为二进制字节码,方便运行时解析。运行期:根据字节码文件分配内存,完成执行操作
第二部分:自动内存管理机制
第2章:Java内存区域与内存溢出的异常
相对于C++而言,Java的好处是开发者并不需要过多的在意内存的回收,这些都有jvm进行处理。但即便这样,也会有一个问题,过度的依赖机器,会出现未知的问题。因而,也需要开发人员做到心中有数。
2.2 运行时数据区域
-
线程私有区
每个线程都会独有一个- 程序计数器:程序计数器用于指示当前线程的执行位置,字节码行号指示器
- 虚拟机栈:可以看作是一个栈结构,入栈出栈的过程就相当于方法的调用和完成过程。调用单位是栈帧。即每个栈帧都是一个单独的方法,其中包含方法内部的所有数据信息,包括:方法的局部变量表、操作数栈、动态链接、方法出口等
- 局部变量表:局部变量表保存了方法的局部基础变量和引用对象的指针,一般以4个字节为一个slot进行存储,其中long和double占用2个slot。局部变量的大小设定,在编译器就已经完成,只需要在运行期进行分配就行。这样便于程序的快速运行
- 本地方法栈:即居民内部的native方法
-
线程共享区
- Java堆:用于存放实例对象和数组,这个世内存管理的重点,后续再说
- 方法区:用于存储类信息、常量池、静态变量等数据。相当于保存了Java程序的结构信息,而不是具体数据
- 运行时常量池:字面量和符号引用。主要保存一些基础的常量,包括String字符串、final修饰的常量。这些变量运行时基本不会变化,直接保存,运行时直接赋值,减少分配内存的开销。
-
直接内存:共享的对外内存。对于数据传输而言,由于数据需要经常进行搬运,如果直接使用堆内存,会进行频繁的调用,因而,jdk1.4中,对于NIO的使用,指定可以直接对外部存储进行调用,大大提高了效率。但是这也会带来OOM异常,但是很多时候不容易发现,因而在使用NIO时,需要重点注意这个
2.3 虚拟机中对象的流程
-
对象的创建:
一下只包括New 对象,不包括数组和class对象- 遇到一个new指令后,会在常量池中查找对应的符号引用,这个符号指引的类,在加载后就有了相关的信息。
- 为对象分配所需的内,一下为分配策略
1.指针碰撞:按顺序实用内存,使用一个指针标记最终的使用位置(一般用于内存规整的情况)
2.空闲列表:随意存放,但是通过一个列表,记录内存的空闲区域。用于后续分配的存放 - 多线程访问的解决:1.CAS操作-2.单独为每个线程分配空间,再整合
- 将内存空间初始化为0值
- 设置对象的一些信息,包括hash码、gc年龄等
- init方法初始化
-
对象的内存布局
对象的内存分3个区域:- 对象头:包括对象的所有信息和对象所属类的类型指针
- 对象数据
- jvm的起始地址必须是8的整数,所以不够的需要填充
-
对象的访问定位
程序运行时通过栈上的引用来对堆中的对象进行操作,所以需要定义指针如何找到对象,一般一下两种- 使用指针:即直接指向对象地址
- 使用句柄:通过一个句柄池,间接指向对象
句柄的优势在于,如果对象地址改变,只需要修改句柄,而不需要修改引用。但是指针引用效率更高(hotspot使用指针)
常见错误的归纳:
// 1.堆溢出:不断地创建对象
// java.lang.OutOfMemoryError: Java heap space
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
// 2 虚拟机栈和本地方法栈:
// 2.1 方法的递归调用
// java.lang.StackOverflowError-JavaVMStackSOF.java:20
public void stackLeak() {
stackLength++;
stackLeak();
}
// 2.2 创建太多的线程,每个线程都要单独分配
// java.lang.OutOfMemoryError: unable to create new native thread
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
});
thread.start();
}
// 3 运行时常量池-不断地创建字符串常量
// OutOfMemoryError: PermGen space - RuntimeConstantPoolOOM.java:18
public static void main(String[] args) {
// 使用List保持着常量池引用,压制Full GC回收常量池行为
List<String> list = new ArrayList<String>();
// 10M的PermSize在integer范围内足够产生OOM了
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
// 4 方法区-需要加载的类信息过多
// Spring、Hibernate对类进行增强时,需要加载大量数类信息,会存在这种情况
// OutOfMemoryError: PermGen space-ClassLoader.java:632
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
// 5 本机直接内存
// OutOfMemoryError-DirectMemoryOOM
/**
* VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
* @author zzm
*/
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
第3章:垃圾收集器与内存分配策略
3.2 对象死亡的判断
一个对象是否可以被回收,可以通过一下方法:
- 引用计数:每个对象都有一个自身的引用计数器。当计数为0时,就判断死亡。无法解决互相引用
- 可达性分析:记录一个根节点到每个对象的引用,相当于树
java中的四种引用:
- 强引用:即一般的引用
- 软引用:内存发生溢出前,就回收
- 弱引用:gc时,就被回收
- 虚引用:外界不知道它的存在,只是在回收时,关联着会收到系统通知
对象的两次判断:
无论是可达性还是计数器,对象在回收前,会调用一次finalize方法,让然后,等待回收,如果期间再次被引用,就可以被救起。finalize方法只会调用一次。
3.3 垃圾收集算法
- 标记-清除:对每个对象进行遍历标记,再次遍历时,清除没有引用的对象(存在碎片)
- 复制算法:将内存分为两块大小相对的区域,一部分用于存储,另一部分用于复制(但是内存只能用一半)一般用于新生代(eden-survivor-survivor)
- 标记-整理:第一次遍历对对象进行标记,存活的对象,移动到一端;然后直接将外界的内存全部清理。这种方式一般用于老年代。即高效,又节省资源
- 分代收集:这种方式是综合前面的算法:新生代使用复制,老年代使用标记-整理;
3.5 垃圾收集器
垃圾收集器是垃圾回收的具体实现
- Serial:单线程,独占(需要用户等待)。一般用于客户端模式:客户端垃圾不多,单个线程完全够用,停顿少
- ParNew:Serial的多线程并行版本,独占。用于服务器端新生代收集器:依赖多线程。配合老年代使用CMS
- Parallel Scavenge:新生代复制算法收集器,并发。与ParNew不同的是,他通过一个吞吐量来控制并行。适应实际应用-即自适应调节策略。
- CMS-并发标记清除:目的是最短停顿时间:主要是四个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
- G1收集器:当前主流
- 初始标记
- 并发标记
- 最终标记
- 筛选标记
并行和并发的区别:
文中的上面的并行指的是所有的垃圾收集器是并行的,用户等待
并发时用户和垃圾线程并发执行,可能互相交叉
GC日志:
3.6 垃圾回收策略
-
对象优先分配在新生代Eden+一个Survivor区。
当新生代不足以分配内存时,就会发生一次Minor DC,GC后,存下来的对象放入另一个Survivor,如果内存不够,直接进入老年代。 -
大对象直接进入老年代。
当一个大对象放入新生代的时候,由于大的占用,会引发不断地GC,因此,为了防止这种情况,大对象直接放入老年代。但是这样也会出现另一个问题,即大对象一般存活时间短,直接放到老年代,存活时间长,对老年代也是压力。所以需要程序员合理设置最大值 -
长期存活的对象进入老年代
对于存活下来的对象,进行年龄统计,到达一定的年龄,也会进入老年代。如果某个年龄的总数大于对象数量的一般,也会将当前年龄的对象全部放入老年代。一般15岁。 -
老年代的fullGC
在发生Minor GC前,老年代会检查,可用空间是否大于新生代所有空间,如果大于,表明没有风险,可以全部容纳,就Minor GC。
再检查老年代可用空间是否大于历次晋升对象的平均大小,如果大于,就MinorGC,否则进行一次Full GC。(仍然有风险,只是概率问题)
第4章 虚拟机监控工具
4.2 命令行工具-bin目录下
// java process status 查看所有的java线程
jsp - l
// 虚拟机持续监控-每250秒对2726线程监控垃圾收集情况,总共20次
jstat -gc 2726 250 20
// java配置信息
jinfo
// java对堆栈跟踪工具-输出线程快照
jstack -l 3500
可视化工具:
- jconsole
- VisulaVm插件
第5章: 实战优化案例
第三部分:虚拟机执行子系统
第6章 类文件结构
6.3 class文件结构
class文件结构以一个字节作为单位,使用16进制表示,即两个16进制位。
- 魔数和class版本:都为4个字节,代表是否能被虚拟机加载;版本便是当前文件版本号
- 常量池:class文件最大的数据区,前2个字节代表常量数量;之后为常量数:包括常量和引用
- 访问标志:2个字节。表示类的访问信息:类还是接口,public还是abstract,final等
- 类索引、父类索引、接口集合:用于确定类的全限定名。单继承,所以,类索引和父类索引都是2个字节,接口是一组u2的组合,第一个表示数量。
- 字段表集合:用于描述接口和类中声明的变量。不包括方法局部变量。第一个代表数量
- 方法表集合:同字段表一样
- 属性表集合:支持前面的信息
6.4 字节码指令
类似于汇编的寄存器操作
- 加载存储指令:load是将局部变量加载到操作栈,store是将从操作栈存储到局部变量表,push是将常量加载到操作栈
- 运算指令:add、sub等
- 类型转换:支持向上转换,向下转换会丢失精度。补充:long可以上升到float,float是存储10的指数次方+有效位数,所以范围很大。
- 对象创建与访问指令:new 、 newarray、getfield、aload、instanceof
- 操作数栈管理指令:pop、pop;dup、dup2;swap
- 控制转移:ifeq、ifle;相当于条件判断
- 方法调用和返回:invoke、return
- 异常处理:athrow直接抛出;catch使用的是异常表而不是指令
- 同步指令:获取对象的监视器Monitor,通过acc_syn访问表示,识别是否进入监视器
第7章 类加载机制
java中,类的加载、连接及初始化都是在运行期间完成的。
周期:加载-验证-准备-初始化-卸载
初始化规定:
- 遇到new、静态字段、静态方法
- 使用reflect对类进行反射调用时
- 初始化类,先初始化父类(如果父类没初始化)
- 虚拟机启动需要一个主类,主类需要先初始化
- 动态语言情况
注意以上的一些案例
7.3 类加载过程
-
1加载
3件事情- jvm通过类的全限定名获取类的二进制流
- 将表示的静态数据结构转为方法区运行时的数据结构
- 在内存生成类的对象,用于方法调用
-
2验证
验证是否符合jvm要求 -
3准备
为类变量分配内存,并初始化。 -
4解析
将常量池的符号引用转为直接引用 -
5初始化-开始执行类中的代码
7.4 类加载器:
类的加载需要合理的管理,否则会存在混乱,同样的名字,代表的内存却不是一样的。所以,这里使用双亲委派模型,即每个类的加载都向上传递,交由父类进行加载,如果父类加载失败,才由自身加载,防止父类和自身加载冲突。即保证只有一个被加载
第8章 虚拟机字节码执行引擎
执行引擎就是通过给定的字节码指令,执行对应的调用过程
8.2 运行时栈帧结构
一个线程会独占一个栈,栈是由多个栈帧组成,每一个栈帧相当于一个线程方法。包含了操作所需的局部变量、操作数栈、动态链接等。线程的执行过程就是方法入栈出栈的过程。栈顶栈帧叫做当前帧,即运行时帧。这些变量的内存占用在编译时就已经确定
- 局部变量表:
即方法内部变量,包括方法的参数和方法的内部定义变量。
局部变量表组成:局部变量表有10种变量:8种基础变量,1个对象引用变量(包括字符串的引用),1个字节码地址(现已不用)。参数的占用以4个字节为一个slot单位,不足补齐。所以long、double占用两个slot。
局部变量表的排序:对于一个栈帧中单独一个方法而言,主要由三部分组成:如果方法是实例方法,那么第一部分参数就是对象的引用,默认隐藏,即this;第二个为方法参数;第三部分为方法内部变量;
slot复用:为了节省栈帧内存,Java局部变量表slot可以复用。即在一个方法内部,代码块中的变量生存周期只在代码块内部,如果出了代码块,即便有引用,也会被回收。但是,这种回收也是不确定的,所以,一个编码规则是:程序员需要主动对不用的对象,赋值为null
public static void main(String[] args) {
{
int[] temp = new int[2014];
}
int t = 0; // 加上这句,主动去复用,才会回收temp,否则,不回收,尽量直接使用temp=null
System.gc();
}
-
操作数栈:
虚拟机栈是方法调用的栈结构,操作数栈是方法内部计算的栈结构。即常见的表达式计算。操作数栈主要处理两类数据:一类是已知的基本数据的加减乘除,另一类是方法传递的加减乘除(即方法返回的结果等) -
动态链接
虚拟机线程共享区有一个方法区,方法区中有一个常量池,用于存储固定的常量和方法名的引用(便于直接查找方法)。动态链接保存的就是当前栈帧需要用到的方法引用,便于运行时直接调用。 -
方法返回地址:
即方法调用完成后,需要恢复上层局部变量表和操作数栈,并将返回值压入操作数栈,并调用PC计数器指向下一个指令。如果运行期出现未处理异常,就会直接导致方法退出
8.3 方法调用
方法调用不同于方法执行,调用只是设定方法调用的引用,相当于把所有的方法通过链表连接起来,作为一个调用顺序。
以下为几种调用方式:
-
解析调用:在class文件时,方法的调用存储的只是方法的符号引用。直到类加载的解析阶段,才会将一部分方法的符号引用转化为直接引用,即最终的引用。这包括invokestatic和invokespecial,即静态方法和私有方法、实例构造器、父类方法4类;另外还有final方法。可以发现这五个类型是一种不变的类型,即方法的指引对象在运行期不会变化(这个需要注意Java多态)
解析调用是一个静态的过程,即在编译期就完全确定,不用等到运行期。 -
分派调用
分派调用时Java多态的一种实现,即重载和重写。分为静态分派和动态分派。
静态分派即在编译器完成分派,即直接能确定的;动态分派是指在运行期完成分派,需要运行时才知道确定类型的。
比如Human man = new Man();man变量有两个类型,分别是编译期和运行期,编译期是Human,即静态类型;运行期是Man,即实际类型。
重载:重载的参数是在编译器确定的
重写:重写的参数是在运行期确定的
比较:
// 1-静态分派-重载
public class StaticDispatch {
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
static abstract class Human { }
static class Man extends Human { }
static class Woman extends Human { }
public void sayHello(Human guy) {
System.out.println("hello, guy");
}
public void sayHello(Man guy) {
System.out.println("hello, man");
}
public void sayHello(Woman guy) {
System.out.println("hello, woman");
}
}
// output:
hello, guy
hello, guy
// 2-动态分派-重写
public class DynamicDispatch {
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
}
// outptu:
man say hello
woman say hello
woman say hello
重载的调用顺序:
当前指定参数-向上类型转换-自动装箱-接口向上-父类向上-可变长参数
类型转换:char-int-long-float-double
- 单分派和多分派
java中的分派,根据两个参数确定。一个是调用的对象,一个是方法的参数对象。
如果只使用一个就是单分派,如果两个都是用就是多分派。
Java中的分派是:静态多分派,动态单分派。
比如A.invoke(B):静态分派既要知道A的类型,也要知道B的类型;而动态分派只需要知道A的类型,就可以确定调用哪个方法
动态分派太过繁杂,为了便于查找,java在方法区中使用了一个虚拟机表,用于保存方法的索引,便于查找
java基于栈的指令集和汇编基于寄存器的指令集
对于1+1操作:
// 基于栈:把两个元素取出来操作,再放回
iconst_1
iconst_2
iadd
istore_0
// 基于寄存器:直接在单个元素上操作,第二个参数是确定的数
mov eax, 1
add eax, 2