在C里面我们想执行一段自己编写的机器指令的方法大概如下:
typedef
void
(*FUNC)(
int
);
char
* str =
"your code"
;
FUNC f = (FUNC)str;
(*f)(0);
|
也就是说,我们完全可以做一个工具,从一个文件中读入指令,然后将这些指令运行起来。上面代码中“编好的机器指令”当然指的是能在CPU上运行的,如果这里我还实现了一个翻译机器:从自己定义的格式指令翻译到CPU指令,那么就可以执行根据自定义格式的代码了。那么上面这段代码是不是相当于最简单的一个虚拟机了?下面来看JVM的总体结构:
ClassLoader的作用是装载能被JVM识别的指令(当然不只是从磁盘文件或内存去装载),那么我们先了解一下该格式:
魔数以及版本就不说了(满大街的文件格式都是这个东西),接着的便是常量池,其中无非是两种东西:
- 字面常量(比如Integer、Long、String等);
- 符号引用(方法是哪里的?什么样的?);
而我们知道,在JVM里面Class都是根据全限定名去找的,那么方法的描述当然也应该如此,那么就得到这些常量之间的关系如下:
在接下来的“访问权限”中表明了该Class是public还是private等,而this&super&interface则表面了“本类”、“继承自哪个类”、“实现了哪些接口”,实际上这里只是保存了代表这些信息的CONSTANT_Class_info的下标(u2)。
感觉这里的NameIndex和DescriptorIndex加起来和NameAndType有点像,那么为什么不直接用一个NameAndType的索引值表示?MethodInfo和FieldInfo之间最大的不同点就是Attributes。比如FieldInfo的属性表中存放的是变量的初始值,而MethodInfo的属性表中存放的则是字节码。那么我们来依次看这些Attributes,首先是Code:
有几个有意思的地方:
- 从Class文件中可以知道在执行的过程中栈的深度;
- 对于非静态方法,编译器会将this通过参数传递给方法;
- 异常表中记录的范围是指令的行数(而不是源代码的);
- 这里的异常是指try-catch中的,而与Code同级的异常表中的则是指throws出去的;
Exceptions则非常简单:
LineNumberTable保存了字节码和源码之间的关系,结构如下:
LocalVariableTable描述了栈帧中局部变量表的变量和源代码中定义的变量之间的关系,结构如下:
SourceFile指明了生成该Class文件的Java源码文件名(比如在一个Java文件中申明了很多类的时候会生成很多Class文件),结构如下:
Deprecated和Synthetic属性只存在“有”和“没有”的区别:
- Deprecated:被程序作者定为不再推荐使用,通过@deprecated注释说明;
- Synthetic:表示字段或方法是由编译器自动生成的,比如<init>;
这也就是为什么Code属性后面会有Attribute的原因?
类加载的时机就很简单了:在用到的时候就加载(废话!)。下来看一下类加载的过程:
执行上面这段过程的是:ClassLoader,这个东西还是非常重要的,在JVM中是通过ClassLoader和类本身共同去判断两个Class是否相同。换句话说就是:不同的ClassLoader加载同一个Class文件,那么JVM认为他们生成的类是不同的。有些时候不会从Class文件中加载流(比如Java Applet是从网络中加载),那么这个ClassLoader和普通的实现逻辑当然是不一样的,通过不同的ClassLoader就可以解决这个问题。
但是允许使用不同的ClassLoader又引发了新的问题:如果我也声明了一个java.lang.Integer,但是里面的代码非常危险,怎么办?这里就引出了双亲委派模式:
除了顶层的启动类加载器外,其余的类加载器都应该有父类加载器(通过组合实现),它在接到加载类的请求时优先委派给父类加载器去完成。
这样的话,在加载java.lang.Integer的时候会优先使用系统的类加载器,这样就不会加载用户自己写的。在Java程序员看到有3种系统提供的类加载器:
- Bootstrap ClassLoader:负责加载<JAVA_HOME>\lib目录中的类库,无法被Java程序直接引用;
- Extension ClassLoader:负责加载<JAVA_HOME>\lib\ext,开发者可以直接使用;
- Application ClassLoader:加载ClassPath上所指定的类库,如果没有自己定义过自己的类加载器则会使用它;
这样默认的类会是有Application ClassLoader去加载类,然后如果发现要使用新的类型的时候则会递归地使用Application ClassLoader去加载(在前面的加载过程中提到)。这样,只有在自己的程序中能使用自己编写的ClassLoader去加载类,并且这个被加载的类是不能被别人使用的。
双亲委派模式不是一个强制性的约束,而是Java设计者推荐给开发者的类加载实现方式。双亲委派模式出现过的3次“破坏”:
- 为了兼容JDK 1.0,建议使用者去覆盖findClass方法;
- 在基础类要访问用户类的代码会出现问题(比如JNDI):线程山下文类加载器;
- 用户的一些需求,比如HotSwap、OSGI等;
加载完完成后,接下来就要看程序是怎么运行的。栈帧是用于支持虚拟机进行方法调用和执行,帧的意思就是一个单位,在调用其他方法的时候会向栈中压入栈帧,结构如下:
在Class文件编译完成之后,在运行的时候需要多少个局部变量就已经确定(在前面Class文件中也已经看到过了),那么这里需要注意这个特性可能会引发GC(具体如何引发就不在这里细说了)。在栈中,总是底层的栈去调用高层的栈(并且一定的相邻的),那么他们在参数传递(返回结果)的时往往是通过将其压入操作数栈,有些虚拟机为了提高这部分的效率使得相邻栈帧“纠缠”在一起:
那么我们接下来要去看是方法是如何执行的,第一个问题就是执行哪个方法?在“面向过程”的编程中似乎不存在在个问题,但是在Java OR C++中这都是比较蛋疼的一个问题。原因就是平时不会这么用,但是你必须去搞明白= =。JVM确定目标方法的时候有两种方法:
- 静态分派:根据参数类型和方法名称来决定调用哪个方法。但是,并不是说没有发现匹配的类型就报错,比如有:func(int a),而在调用func('a')的时候也会调用该方法(当然是在没有func(char a)的前提下),这样给人的关键就有点像一个处理的链条。不管多么复杂,这些都是在编译期间确定的,因为这里是向上找的。
- 动态分派:最普遍的就是Interface a = new Implements(),a调用方法到底应该是哪个类的在编译期间是无法确定的。其实动态分派实现起来也很简单:在调用方法的时候先拿到对象的实际类型。
其实“静态”和“动态”给人的感觉还是比较模糊的,“静态分派”给人的感觉是根据参数的类型向上查找方法,“动态分派”给人的感觉则是根据实例的真实类型向上查找。虚拟机优化动态分派的效率一般是为类在方法区中建立一个虚方法表:
虚方法表中存放各个方法实际入口地址,如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类方法表中的地址将会被替换为指向子类实现版本的入口地址。其实往简单里说,就是一个预处理。
具体单个方法的执行非常简单,写一个简单的程序然后使用javap -c,再结合每条指令的含义就能大概知道程序时怎么执行以及返回的了(大体上就是基于栈),这里就不深入和细说了。
一般情况下,从Java文件到运行起来,总的会经历两个阶段:Java到Class文件和执行Class文件。第一个阶段其实就是编译了,在这个过程中比较有意思的是“语法糖”(其他的比如词法分析和语法分析就不说了,此处省略1万字~!~)。所谓Java的语法糖是:for遍历的简写、自动装箱、泛型等(其实有没有感觉String+String也是语法糖,在实际中会变成StringBuffer的append)。其中比较有意思的是泛型:
Java中的泛型和C++中的泛型原理是上不一样的:对于C++来说List<A>和List<B>就是两个东西,而在Java中List<A>和List<B>都是List<Object>,因为在Java中Object是所有对象的父对象,那么Object o可以指向所有的对象,那么就可以用List<Object>来保存所有的对象集了(感觉实现的有点废)。
这里涉及到一个问题就是对象删除,比如下面代码:
static void func(List<Integer> a){
return;
}
在使用javap查看生成的Class的时候会发现:
static void func(java.util.List);
Signature: (Ljava/util/List;)V
Code:
0: return
其中根本没有任何Integer的痕迹,但是如果加上返回值,也就是:
static Integer func(List<Integer> a){
return null;
}
此时再查看的时候就会变成:
static java.lang.Integer func(java.util.List);
Signature: (Ljava/util/List;)Ljava/lang/Integer;
Code:
0: aconst_null
1: areturn
通过泛型实现的原理可以理解很多在实际中会遇到的问题,比如使用List的时候莫名其妙的类型强制转换错误。
接下来开始讨论第二个部分,也就是Class文件的实际的执行。在C++中常会提到的两个概念是:Debug和Release,而在Java中常提到的两个概念是Server和Client(虽然他们划分的根据完全不一样),Client和Server两种模式对应两种编译器:
- Client对应C1编译:将字节码编译成本地代码并进行耗时短且可靠的优化,在必要的时候加入性能监控。
- Server对应C2编译:将字节码编译成本地代码并进行耗时比较长的优化,还可能会根据性能监控的结果进行一些不可靠激进的优化。
在监测器发现有热点代码(被调用了很多次的方法或者是执行很多次的循环体)的情况下,将会想即时编译器提交一个该该方法的代码编译请求。当这个方法再次被调用时,会先检查改方法是否存在被JIT编译过的版本,如果存在则优先使用编译后的本地代码。在默认的情况下,编译本地代码的过程和旧的代码(也就是解释执行字节码)是并行的,可以使用-XX:-BackgroundCompilation来禁止后台编译,也就是说执行线程会登岛编译完成后再去执行生成的本地代码。
在具体编译优化的时候有一个比较好玩的东西,逃逸分析(所谓逃逸是指能被从方法外引用),对于不会逃逸的对象可以进行优化:
- 在栈上分配对象,可以减少GC的压力;
- 不需要对为逃逸的对象进行线程同步;
- 如果一个对象无法逃逸,可以在方法里面不申明这个对象,而是放一些“零件”;
关于Java和C++效率的问题,感觉讨论起来就没有什么意义了:语言到最后肯定是要生成机器指令的,在语言的机制上面各有千秋,导致不同的语言之间生成机器指令的过程可能不同,但是这个生成的过程跟我们这些码农没有半毛钱关系(更准确的说我们生成的过程我们毛都不知道),所以在搞清楚之前就不要争到底哪个效率高(甚至是哪个更好)。
程序的并发主要是考虑不同的线程操作同一块内存时候可能发生的一些问题(至于文件锁之类的东西,咳咳),首先就先了解线程和内存的关系:
这里的主内存就像是内存条,工作内存就像是寄存器+Cache。Java内存模型定义了8中操作,他们的执行如下:
Java虚拟机中最轻量级的同步机制:volatile,它的性质如下:
- 变量发生修改的时候会立刻被其他线程看到;
- 禁止指令重排序优化;
从Java内存模型操作的角度来看volatile的实现还是挺简单的:在use之前必须load,在assign之后必须store,这样就保证了每次用都是从主内存中读取,每次赋值之后都会同步到主内存(貌似说的是废话)。线程的同步主要是从三个方面考虑:
- 原子性:Long和Double需要特殊考虑;
- 可见性;除了volatile之外还有final(synchronized就不说了吧);
- 有序性:指令重排,当然可以禁止指令重排;
如果任何时候都考虑同步那代码写起来就累死了。下面是Java内存模型的天然先行发生关系:
- 控制流被执行的顺序和代码的顺序保持一致;
- unlock先行发生于后面对同一个锁的lock操作;
- 对volatile变量的写操作先行于后面对这个变量的读操作;
- Thread的start方法先行于线程的任何一个动作;
- 线程的所有动作都先行于线程的终止检测;
- 对线程interrupt方法的调用先行于被中断线程的代码检测到的中断事件的发生;
- 对象的初始化完成先行于finalize方法调用;
- 传递性;
其实上面的这八条规则还是很有意思的,如果其中的某一条不成立会发生什么?说到底Java线程还是用户级的线程,那么它究竟是个什么东西(在学C的时候也纠结过这个问题- -)。实现线程主要有几种方式:
- 使用一个内核线程(轻量级进程)来代理;
- 完全在用户态实现,内核都感觉不到;
- 用户和内核混合实现,各自做自己擅长的事情;
这里就不深入的去看了(虽然这里的介绍根没说一样),想想看都知道不同虚拟机在不同的操作系统上面的实现方式很可能是不一样,如果想深入看还是pthread比较有意思一点。关于线程的其他要注意的地方(比如状态转移什么的)就不在这里讨论了。
线程安全:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
Java中线程共享的变量可以分为以下五种:
- 不可变:这个就不需要解释了(并不一定非得用final修饰);
- 绝对线程安全:也就是满足上面的线程安全描述的;
- 相对线程安全:简单的说应该是对单个行为的调用不会出错;
- 线程兼容:对象并不是线程安全,但可以通过调用方的同步来弥补;
- 线程对立:不管调用方怎么处理都不能在多线程环境下使用;
锁的话有以下几种实现方式:
- 互斥同步,并不是说等待的线程会一直等下去;
- 非阻塞同步,乐观(冲突并没有我们想象的那么多);
如果线程之间的切换非常频繁的话自旋锁是一个不错的选择,这样就不需要线程切换时候的系统调用的开销了。如果一个任务能够很快的完成的话,将整个过程都锁住或许是个不错的选择(而不是给每个子过程上锁)。其他的锁优化包括“轻量级锁”和“偏心锁”。