赞
踩
Java源码 —— 编译器 —— JVM可执行的Java字节码(class文件) —— JVM —— JVM解释器 ——机器可执行的二进制文件 —— 程序运行
编译型语言:通过编译器,将源代码编译成机器码后才能执行的语言。
优点:编译只做一次,执行效率高
缺点:根据不同的操作系统需要生成不同的可执行文件
解释型语言:不需要编译,在运行程序时才逐行进行翻译
优点:平台兼容性好
缺点:每次运行时都要解释一遍,性能低
Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。 由于字节码并不专对一种特点的机器,因此Java程序无须重新编译便可在多种不同的机器上运行。 可以做到“一次编写,到处运行”。
线程私有的区域:
线程共享的区域
其他区域
HotSpot 使用了称之为 Thread-Local Allocation Buffers (TLABs) 的技术,该技术能改善多线程空间分配的吞吐量。首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区中进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了它的 TLAB,它才需要使用同步来获取一个新的缓冲区。HotSpot 使用了多项技术来降低 TLAB 对于内存的浪费。比如,TLAB 的平均大小被限制在 Eden 区大小的 1% 之内。TLABs 和使用指针碰撞的线性分配结合,使得内存分配非常简单高效,只需要大概 10 条机器指令就可以完成。
由于永久代内存经常不够用或发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen,JDK8废除了永久代,改用元数据。
元空间是方法区的在HotSpot jvm 中的实现,方法区主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
Java中引用有四种,分别是强、软、弱、虚。这四种引用的区别就在于GC的过程中:
分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;
清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header信息),则将其回收。
不足:标记和清除过程效率都不高,会产生大量碎片,内存碎片过多可能导致无法给大对象分配内存。
将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和 使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间。
不足:
将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
标记—整理算法和标记—清除算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存,因此其不会产生内存碎片。标记—整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。
不足:
效率不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
分代回收算法实际上是把复制算法和标记整理法的结合,并不是真正一个新的算法,一般分为:老年代(Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。
新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。
老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对象需要回收,故采用标记清除或者标记整理算法
每次分配内存,如果对象比较大的话直接进入老年代。否则,先进入Eden区和一个Survivor区,同时会为每一个对象设一个年龄值。之后会周期性的在某个安全点(Savapoint)。 检查一下,当Eden区满时,对于新生代的对象,将可回收的对象回收掉,将剩余的对象复制到另一个Survivor区,这一过程中会对年龄值加一。(空间分配担保:如果此时Survivor放不下了,则直接进入老年代),这一过程叫做Minor GC,是属于新生代的GC。
当某些对象年龄值比较大时,会将他们移动到老年代去(此外还有动态年龄判定:当Survior空间中相同年所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。)当然在这之前会先查看一下老年代剩余空间是否满足移动。如果不能满足,就会对老年代进行一次GC,这一过程叫做Full GC。而这个检查对象是否可GC得时机,也就是GC的时机,一般是确定的被称作“安全点”。在这一时机进行检查,是不会影响程序正常运行的。
除此之外,其他情况也会发生Full GC:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法去空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
在这之前,需要提到一个概念:Stop the world
Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。
上面讲到,线程需要在“安全点”进行检查是否可GC,如果可以则进行GC,因此到了安全点时,需要Stop-The-World,具体方式为主动式中断(设置一个标志,和安全点重合,各个线程主动轮询这个标志,发现中断标志为真就挂起自己。)
安全点的选定原则:“是否具有让程序长时间执行”,即执行序列可以复用,一般选在方法调用,循环跳转,抛出异常。
安全区域:线程阻塞时无法到安全点挂起,故采用Sava Region
安全区域指在一段代码片段中,引用关系不会发生变化,在该区域任何地方发生GC都是安全的,当代码执行到安全区域时,标识自己已进入安全区域,如果在这段时间里JVM发起GC,就不用管在安全区域里的线程了;在线程离开安全区时,会检查系统是否进行GC,如果是,则GC完成后再离开安全区域。
OopMap:快速找到GC Roots
垃圾收集时,需要对全局性引用和执行上下文进行检查,如果要逐个检查这里面的引用,消耗太大。因此通过OopMap来记录哪些地方存放着对象引用,哪些地方没有存放对象引用,消耗太大。
OopMap的更新也在安全点完成。
CMS 收集过程首先是一段小停顿 stop-the-world,叫做 初始标记阶段(initial mark),用于确定 GC Roots。然后是 并发标记阶段(concurrent mark),标记 GC Roots 可达的所有存活对象,由于这个阶段应用程序同时也在运行,所以并发标记阶段结束后,并不能标记出所有的存活对象。为了解决这个问题,需要再次停顿应用程序,称为 再次标记阶段(remark),遍历在并发标记阶段应用程序修改的对象(标记出应用程序在这个期间的活对象),由于这次停顿比初始标记要长得多,所以会使用多线程并行执行来增加效率。
再次标记阶段结束后,能保证所有存活对象都被标记完成,所以接下来的 并发清理阶段(concurrent sweep) 将就地回收垃圾对象所占空间。
如果老年代空间不足以容纳从新生代垃圾回收晋升上来的对象,那么就会发生 concurrent mode failure,此时会退化到发生 Full GC,清除老年代中的所有无效对象,这个过程是单线程的,比较耗时
另外,即使在晋升的时候判断出老年代有足够的空间,但是由于老年代的碎片化问题,其实最终没法容纳晋升上来的对象,那么此时也会发生 Full
GC,这次的耗时将更加严重,因为需要对整个堆进行压缩,压缩后年轻代彻底就空了。
并发标记和并发清理两个耗时最长的阶段不需要STW,可以和用户线程并发执行,因此缩小了响应时间;但初始标记阶段和再次标记阶段还是需要STW的。
缺点:CPU资源敏感,浮动垃圾
G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,这一点非常重要。
G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。
如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。
首先是内存划分上,之前介绍的分代收集器将整个堆分为年轻代、老年代和永久代,每个代的空间是确定的。
而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。
执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间,这也是为什么 G1 被取名为 Garbage-First 的原因。
在 G1 中,目标停顿时间非常非常重要,用 -XX:MaxGCPauseMillis=200 指定期望的停顿时间。
G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收。
我们要知道 G1 不是一个实时收集器,它会尽力满足我们的停顿时间要求,但也不是绝对的,它基于之前垃圾收集的数据统计,估计出在用户指定的停顿时间内能收集多少个区块。
注意:G1 有和应用程序一起运行的并发阶段,也有 stop-the-world 的并行阶段。但是,Full GC 的时候还是单线程运行的,所以我们应该尽量避免发生 Full GC,后面我们也会介绍什么时候会触发 Full GC。
G1工作流程:
1.初始标记 2.并发标记(只有该阶段是并发的)
3.最终标记 4.筛选回收
Class文件是一组以8位字节为基础单位的二进制流,非常紧凑的排列在一起,很严格的规定了第几位到第几位是什么
ClassFile { u4 magic; #魔数 u2 minor_version; #版本号 u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; }
他们的作用就是在虚拟机运行时,通过常量池入口,在常量池中找到对应的符号引用,从而找到引用的类或者方法等。
常量的格式:
cp_info {
u1 tag;
u1 info[];
}
常量的类型有14种,对应不同的tag
CONSTANT_Utf8:存放UTF-8编码的字符串;
CONSTANT_Class:存储类或者接口的符号引用:
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
这里的name_index并不是直接的字符串,而是指向常量池中cpInfo数组的name_index项,且cpInfo[name_index]一定是CONSTANT_Utf8格式。
常量池解析完毕后,就可以供后面的数据使用了,比方说ClassFile中的this_class指向的就是常量池中格式为CONSTANT_Class的某一项,那么我们就可以读取出类名:
int classIndex = U2.read(inputStream);
ConstantClass clazz = (ConstantClass) constantPool.cpInfo[classIndex];
ConstantUtf8 className = (ConstantUtf8) constantPool.cpInfo[clazz.nameIndex];
classFile.className = className.value;
System.out.print("classname:" + classFile.className + "\n");
method_info {
u2 access_flags; #访问标志
u2 name_index; #方法名索引
u2 descriptor_index; #方法描述符索引
u2 attributes_count;
attribute_info attributes[attributes_count];#属性数组
}
这里要强调的是属性数组,因为字节码指令就存储在这个属性数组里。属性有很多种,比如说异常表就是一个属性,而存储字节码指令的属性为CODE属性。属性的通用格式为:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
根据attribute_name_index可以从常量池中拿到属性名,再根据属性名就可以判断属性种类了。
Code属性的具体格式为:
Code_attribute { u2 attribute_name_index; u4 attribute_length; u2 max_stack; u2 max_locals; #局部变量表存储空间 u4 code_length; u1 code[code_length]; #存放字节码 u2 exception_table_length; { u2 start_pc; u2 end_pc; u2 handler_pc; u2 catch_type; } exception_table[exception_table_length]; u2 attributes_count; attribute_info attributes[attributes_count]; }
其中code数组里存储就是字节码指令,那么如何解析呢?每条指令在code[]中都是一个字节,我们平时javap命令反编译看到的指令其实是助记符,只是方便阅读字节码使用的,jvm有一张字节码与助记符的对照表,根据对照表,就可以将指令翻译为可读的助记符了。
类从被加载到内存中开始,到卸载出内存,经历了加载、连接、初始化、使用四个阶段,其中连接又包含了验证、准备、解析三个步骤。但是Java语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。
初始化的时机有以下几种:
这些情况,属于对类的主动引用
除了这5种场景,所有引用类的方式都不会触发初始化,称为被动引用:
如通过子类引用父类的静态方法,通过数组定义来引用类,引用常量池中的常量时,都不会触发初始化。
因为加载这个过程没有限制具体的来源,所以衍生出了很多新东西,比如Jar包的读取,从网络中加载类等。
这是对于简单类而言的。对于数组,不会通过类加载器加载,而是由虚拟机直接创建,之后才会递归的加载数组中的引用类。
验证阶段非常重要,但不一定必要,如果所有代码极影被反复使用和验证过,那么可以通过虚拟机参数-Xverify: none来关闭验证,加速类加载时间。
这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显示的初始化这些字段,所以准备阶段不会做这些事情。假设有:
public static int value = 123;
value在准备阶段的初始值为0而不是123,只有到了初始化阶段,value才会为0。
1.类或接口的解析:
设Java虚拟机在类D的方法体中引用了类N或者接口C,那么会执行下面步骤:
如果C不是数组类型,D的定义类加载器被用来创建类N或者接口C。加载过程中出现任何异常,可以被认为是类和接口解析失败。
如果C是数组类型,并且它的元素类型是引用类型。那么表示元素类型的类或接口的符号引用会通过递归调用来解析。
检查C的访问权限,如果D对C没有访问权限,则会抛出java.lang.IllegalAccessError异常。
2. 字段解析:
要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析
如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。否则先去找父接口,找不到再去找父类,最后进行权限验证。
在实际的实现中,要求可能更严格,如果同一字段名在C的父类和接口中同时出现,编译器可能拒绝编译。
3.类方法解析,接口方法解析…
Clinit<>()方法是由编译器自动收集类中的所有**类变量的赋值动作和静态语句块(static语句块)**中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
Clinit<>()方法与类的构造函数init<>()方法不同,它不需要显示地调用父类构造器,虚拟机会在子类的Clinit<>()方法执行之前,父类的<clinit()已经执行完毕
但是接口中的<clinit()不需要先执行父类的,只有当父类中定义的变量使用时,父接口才会初始化。除此之外,接口的实现类在初始化时也不会执行接口的<clinit()方法。
虚拟机会保证一个类的**<clinit()方法在多线程环境中能被正确的枷锁、同步。如果多个线程初始化一个类,那么只有一个线程会去执行<clinit()方法,其它线程都需要等待。**
加载的工具——类加载器
前面说过,第一步“加载”过程,要通过一个类的全限定名来获取这个类的二进制字节流。这个过程,是要借助于一股虚拟机外部的工具来进行的,这一工具就是类加载器。每一个类,都有一个针对他的类加载器。两个类是否相同,不但要比较他本身,还要比较他们的类加载器。
而具体的加载逻辑,被称为“双亲委派模型”,即首先有一个根部的加载器“启动类加载器”,其下有一个儿子叫“扩展类加载器”,其下是“应用程序类加载器”,最后是“自定义类加载器”。具体流程:
一个类收到了加载的请求,首先会把请求委托给父类加载,每一个加载器都是如此。这样最终会把请求交给根节点的“启动类加载器”。之后如果父加载器可以加载,就会直接加载。否则,会将请求再传下来。
这样可以避免程序员自己随意串改系统级的类。
双亲委派模型是可以被打破的:
线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。
有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI服务使用这个线程上下文类加载器去加载所需要的 SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器 ,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI 、JDBC、JCE、 JAXB 和JBI等。
https://blog.csdn.net/a724888/article/details/78396462
Java Web服务器需要满足:
由于存在上述问题,在部署Web应用时,单独的一个Class Path就无法满足需求了,所以各种 Web容都“不约而同”地提供了好几个Class Path路径供用户存放第三方类库,这些路径一般都以“lib”或“classes ”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库 。
在Tomcat目录结构中,有3组目录(“/common/”、“/server/”和“/shared/”)可以存放Java类库,另外还可以加上Web 应用程序自身的目录“/WEB-INF/” ,一共4组,把Java类库放置在这些目录中的含义分别如下:
①放置在/common目录中:类库可被Tomcat和所有的 Web应用程序共同使用。
②放置在/server目录中:类库可被Tomcat使用,对所有的Web应用程序都不可见。
③放置在/shared目录中:类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
④放置在/WebApp/WEB-INF目录中:类库仅仅可以被此Web应用程序使用,对 Tomcat和其他Web应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,
CommonClassLoader,Catalina ClassLoader,SharedClassLoader,WebAppClassLoader
如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的,那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢?
答案是使用线程上下文类加载器来实现的,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。看spring源码发现,spring加载类所用的Classloader是通过Thread.currentThread().getContextClassLoader()来获取的,而当线程创建时会默认setContextClassLoader(AppClassLoader),即线程上下文类加载器被设置为 AppClassLoader,spring中始终可以获取到这个AppClassLoader( 在 Tomcat里就是WebAppClassLoader)子类加载器来加载bean ,以后任何一个线程都可以通过 getContextClassLoader()获取到WebAppClassLoader来getbean 了 。
JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。通俗点来说,也就是 JVM 解析字节码指令,输出运行结果的一个过程。
在 Java 中,一个栈帧对应一个方法调用,方法中需涉及到的局部变量、操作数,返回地址等都存放在栈帧中的。每个方法对应的栈帧大小在编译后基本已经确定了,方法中需要多大的局部变量表,多深的操作数栈等信息早以被写入方法的 Code 属性中了。所以运行期,方法的栈帧大小早已固定,直接计算并分配内存即可。
局部变量表第一项是名为 this 的一个类引用,它指向堆中当前对象的引用。接着就是我们的方法参数
操作数栈:操作数栈也称作操作栈,它不像局部变量表采用的索引机制访问其中元素,而是标准的栈操作,入栈出栈,先入后出。操作数栈在方法执行之初为空,随着方法的一步一步运行,操作数栈中将不停的发生入栈出栈操作,直至方法执行结束。
返回地址:一个方法在调用另一个方法结束之后,需要返回调用处继续执行后续的方法体。那么调用其他方法的位置点就叫做「返回地址」,我们需要通过一定的手段保证,CPU 执行其他方法之后还能返回原来调用处,进而继续调用者的方法体。这个返回地址往往会被提前压入调用者的栈帧中,当方法调用结束时,取出栈顶元素即可得到后续方法体执行入口。
因为往往一条虚拟机指令要求调用某个方法,但是该方法可能会有重载,重写等问题,那么虚拟机又该如何确定调用哪个方法呢
首先我们要谈谈这个解析过程,从上篇文章中可以知道,当一个类初次加载的时候,会在解析阶段完成常量池中符号引用到直接引用的替换。这其中就包括方法的符号引用翻译到直接引用的过程,但这只针对部分方法,有些方法只有在运行时才能确定的,就不会被解析。我们称在类加载阶段的解析过程为「静态解析」。
那么哪些方法是被静态解析了,哪些方法需要动态解析呢?
Object obj = new String("hello");
obj.equals("world");
Object 类中有一个 equals 方法,String 类中也有一个 equals 方法,上述程序显然调用的是 String 的 equals 方法。那么如果我们加载 Object 类的时候将 equals 符号引用直接指向了本身的 equals 方法的直接引用,那么上述的 obj 永远调用的都是 Object 的 equals 方法。那我们的多态就永远实现不了。
只有那些,「编译期可知,运行时不变」的方法才可以在类加载的时候将其进行静态解析,这些方法主要有:private 修饰的私有方法,类静态方法,类实例构造器,父类方法。
其余的所有方法统称为「虚方法」,类加载的解析阶段不会被解析。这些方法的调用不存在问题,虚拟机直接根据直接引用即可找到方法的入口,但是「非虚方法」就不同了,虚拟机需要用一定的策略才能定位到实际的方法,下面我们一起来看看。
public class Father { } public class Son extends Father { } public class Daughter extends Father { } public class Hello { public void sayHello(Father father){ System.out.println("hello , i am the father"); } public void sayHello(Daughter daughter){ System.out.println("hello i am the daughter"); } public void sayHello(Son son){ System.out.println("hello i am the son"); } } public static void main(String[] args){ Father son = new Son(); Father daughter = new Daughter(); Hello hello = new Hello(); hello.sayHello(son); hello.sayHello(daughter); }
输出结果
hello , i am the father
hello , i am the father
首先需要介绍两个概念,「静态类型」和「实际类型」。静态类型指的是包装在一个变量最外层的类型,例如上述 Father 就是所谓的静态类型,而 Son 或是 Daughter 则是实际类型。
我们的编译器在生成字节码指令的时候会根据变量的静态类型选择调用合适的方法。就我们上述的例子而言:
这两个方法就是我们 main 函数中调用的两次 sayHello 方法,但是你会发现传入的参数类型是相同的,Father,也就是调用的方法是相同的,都是这个方法:
所有依赖静态类型来定位方法执行版本的分派动作称作「静态分派」,而方法重载是静态分派的一个典型体现。但需要注意的是,静态分派不管你实际类型是什么,它只根据你的静态类型进行方法调用。
public class Father {
public void sayHello(){
System.out.println("hello world ---- father");
}
}
public class Son extends Father {
@Override
public void sayHello(){
System.out.println("hello world ---- son");
}
}
public static void main(String[] args){
Father son = new Son();
son.sayHello();
}
输出结果:
hello world ---- son
显然,最终调用了子类的 sayHello 方法,我们看生成的字节码指令调用情况:
看到没?编译器为我们生成的方法调用指令,选择调用的是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?
当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual 指令需要完成以下几个步骤才能实现对一个方法的调用:
所以,我们此处的示例调用的是子类 Son 的 sayHello 方法就不言而喻了。
至于虚拟机为什么能这么准确高效的搜索某个类中的指定方法,各个虚拟机的实现各有不同,但最常见的是使用「虚方法表」,这个概念也比较简单,就是为每个类型都维护一张方法表,该表中记录了当前类型的所有方法的描述信息。于是虚拟机检索方法的时候,只需要从方法表中进行搜索即可,当前类型的方法表中没有就去父类的方法表中进行搜索。
静态分派属于多分派(根据多个宗量对目标方法进行选择),动态分派属于单分派(根据一个宗量对目标方法进行选择)
动态类型语言的一个关键特征就是,类型检查发生在运行时。也就是说,编译期间编译器是不会管你这个变量是什么类型,调用的方法是否存在的。
静态语言会在编译期检查变量类型,并提供严格的检查,而动态语言在运行期检查变量实际类型,给了程序更大的灵活性。各有优劣,静态语言的优势在于安全,缺点在于缺乏灵活性,动态语言则是相反的。
JDK1.7 提供了两种方式来支持 Java 的动态特性,invokedynamic 指令和 java.lang.invoke 包。
总结一下,HotSpot 虚拟机基于操作数栈进行方法的解释执行,所有运算的中间结果以及方法参数等等,基本都伴随着出入栈的操作取出或存储。这种机制最大的优势在于,可移植性强。不同于基于寄存器的方法执行机制,对底层硬件依赖过度,无法很轻易的跨平台,但是劣势也很明显,就是同样的操作需要相对更多的指令才能完成。
作者:YangAM
链接:https://juejin.im/post/5abc97ff518825556a727e66
来源:掘金
早期阶段,可以概括的看做前端编译器将.java转化为.class的过程。这一阶段的优化又可以称作编译期优化。
这一阶段其实和其他语言的编译期优化类似,无非就是词法、语法分析,语义分析,然后做一些语言层面的优化。比如,语法糖、注解的处理,还有字符串拼接。Java语法糖不多,但是挺实用的,诸如类型擦除啊,自动拆箱、装箱啊。注解是在编译时进行优化,具体在运行时才会体现出作用。
Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换成原来的原生类型了,并且在相应的地方插入了强制转型代码。伪泛型
自动装箱、拆箱在编译之后会被转化成对应的包装和还原方法,如Integer.valueOf()与Integer.intValue(),而遍历循环则把代码还原成了迭代器的实现,变长参数会变成数组类型的参数。
然而包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们的equals()方法不处理数据转型的关系。
Java语言也可以进行条件编译,方法就是使用条件为常量的if语句,它在编译阶段就会被“运行”:只能是条件为常量的if语句,这也是Java语言的语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除掉
Java程序最初是通过解释器进行解释执行的,当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译时间,立即执行;当程序运行后,随着时间的推移,编译期逐渐发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。解释执行节约内存,编译执行提升效率。
HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler(C1编译器)和Server Compiler(C2编译器),默认采用解释器与其中一个编译器直接配合的方式工作
在运行过程中会被即时编译器编译的“热点代码”有两类:
1.被多次调用的方法:由方法调用触发的编译,属于JIT编译方式
2.被多次执行的循环体:也以整个方法作为编译对象,因为编译发生在方法执行过程中,因此成为栈上替换(OSR编译)
热点探测判定方式有两种:
1.基于采样的热点探测:虚拟机周期性的检查各个线程的栈顶,如果某个方法经常出现在栈顶,则判定为“热点方法”。(简单高效,可以获取方法的调用关系,但容易受线程阻塞或别的外界因素影响扰乱热点探测)
2.基于计数的热点探测:虚拟机为每个方法建立一个计数器,统计方法的执行次数,超过一定阈值就是“热点方法”。(需要为每个方法维护计数器,不能直接获取方法的调用关系,但是统计结果精确严谨)
编译优化技术:
公共子表达式消除,数组边界消除,方法内联等等。。。。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。