深入理解java虚拟机 第二版 笔记

2018-05-15 -> 2018-07-12

1 hello world

书:《深入理解java虚拟机》第二版

2 java内存区域与内存溢出异常

java自动管理内存释放

运行时 数据区

  • 方法区 method area// 存储已被虚拟机加载的类信息 常量 静态变可以看做堆的逻辑部分 别名non-heap

  • 虚拟机栈VM stack // 线程私有 java 方法服务

  • 本地方法栈 native method stack// 虚拟机用到的native 方法服务

  • 堆heap// 几乎所有对象实例

  • 程序计数器 Program counter register// 这个java 模拟出类似汇编pc的一个自己字节码的pcr//当前在执行字节码时 有指向,执行机器码时指向空

  • 运行时常量池 //方法区一部分 编译时期的字面量和符号引用

数据区以外

  • 直接内存,JDK中NIO库,以及相关操作方法,相当于更好的控制内存了???

2.3.1 对象

new

  • 去常量池中找类的符号引用,检查是否被加载解析 初始化过(7章)
  • 从堆上新生对象分配内存//赋值0 虚拟机中额外的标识操作 markword 比如哈希、GC分代年龄、元数据、偏向线程ID, init()

访问

  • 通过栈上记录对对象的引用->句柄池(到对象实例数据的指针 到对象类型数据的指针)/到对象实例的指针 对象示例数据->实例

outofmemory实战的VM参数-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError,前面参数是设置初始化和上限大小,后面是帮助dump

可以用相应IDE的工具来查看GC Roots的引用链

String.intern() 是native方法,如果字符串常量池中已经包含一个等于此String 对象的字符串,则返回代表池中这个字符串的String对象,否则将此String 对象所包含的字符串添加到常量池中,并且放回此string对象的引用// 1.6->1.7版本更换时的改动

方法区溢出,类的回收判定咳咳,大量jsp或动态产生JSP文件的应用

3 垃圾收集器GC与内存分配策略

  • 哪些内容需要回收
  • 什么时候回收
  • 如何回收

对象死亡判断–引用计数算法,引用+1,引用失效-1,简单 判定效率高,大部分情况下不错,但无法解决循环引用的问题

example: struct s{s * next};s* a,s* b;a->next=&b,b->next=&a;a=NULL,b=NULL;

VM 参数-verbose:gc -Xmx20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

可达性算法,通过GC Roots 向下搜索(引用链),java中可以作为GC Root的对象包括

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(native方法)引用的对象

引用的定义

  • 强引用(Object obj = new Object())
  • 软引用(将要溢出时回收)
  • 弱引用(回收时 销毁)
  • 虚引用(用来作为被回收时的通知用的)

没有覆盖finalize或finalize已经被调用过(也就是每个实例的finalize只会执行一次 所以也只有一个自救的机会),则没有必要执行。所以finalize()时是最后一次逃脱销毁的时刻。然而笔者并不鼓励用这个方法,建议大奖忘掉,try-finally或其它方法可以做得更好

回收方法区

方法区(hotspot中的永久代)也有垃圾回收(主要是废弃常量 无用的类),但性价比低。新生代中垃圾回收可以回收75-90%的空间,

废弃常量比较容易判断

无用的类 回收的前提条件(也只是 可以 回收,和对象不同 对象不使用必然回首):该类的所有实例被回收,加载该类的ClassLoader已经被回收,该类的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

-verbose:class -XX:+TraceClassLoading -XX:+TraceClassUnLoading

垃圾收集算法

标记清除算法(mark-sweep):最基本的 也就是 标记一轮,收集一轮,后面的基于该算法改进

复制算法copying,内存化为两半A、B,只用A,标记,收集把剩余的移动到B,只用B,标记B,收集把剩余的移动到A,代价是一半的内存,解决的是空闲碎片问题

  • 商业VM中采用这个方法来回收新生代,而IBM研究表明,98%的新生代 朝生夕死,所以并不需要1:1的分配,而是用一个大的Eden和两个小的survivor大约8:1:1的分类,也就是每次能用90%完整内存

标记整理算法,标记,收集并整理,和复制不同的是不用浪费空间,而且相对来说 如果存活率高的化,这样的代价相对少,用于老年代

分代收集=按需具体新生代、老年代不同的手机算法。

HotSpot 算法实现

GC发生时需要停顿所有JAVA线程执行,所以从GC roots的扫描要快,HotSpot通过OopMap的数据结构,在类加载完成的时候就把对象内的偏移量对应的数据类型计算出,在JIT编译过程中也会记录栈和寄存器中哪些是引用,这样扫描就直接有信息了

安全点:如果每条指令都生成对应OopMap将会要大量额外空间,所以只会在safepoint才会暂停开始GC,例如方法调用,循环跳转,异常跳转,并且让所有线程都运行到最近的安全点停下来,

  • 抢先式中断,让没跑到safepoint继续跑,已经到了的等待,,,,几乎没有VM这样用
  • 主动式中断,每个线程都去找GC的中断标志,在每次到safepoint时检查标志

然而可能有线程sleep或blocked【也就是不执行】,需要安全区域(safe region)来解决,所谓安全区域是只一个 引用关系没有变化的区域,在线程离开safe region时,检查是否GC完成,没有则等待,

垃圾收集器

Yound generation/ tenured generation

7种

  • serial(年轻代) 单线程收集器->所有线程到safepoint->”stop the world”->GC ,在client模式下的VM中的新生代依然是一个好的选择
  • ParNew(年轻代) serial的多线程版本,和上面不同的是 GC的过程是多线程收集,server模式下新生代首选, 只有它目前能和CMS配合工作
  • Parallel Scavenge 是新生代收集器,复制算法 并行多线程手机,主要特点在于可控 CPU使用的吞吐量,能够动态根据吞吐量需求调整 大小
  • serial old 老年代版本 标记整理 主要是client模式下vm用
  • parallel old 老年代 标记整理,说这个配合paralle scavenge使用
  • CMS 收集器 以获取最短回收停顿为目标的收集器,(Mark Sweep) 标记-清除算法
  • G1 当今最前沿的成果之一 直到jdk7u4认为它达到足够成熟的商用程度

CMS:

  • 初始标记 记录GC Roots能够直接关联道德对象 很快 // 需要stop the world
  • 并发标记 gc root tracing
  • 重标记 remark 【解决运行时产生的变动的部分?】 // 需要stop the world
  • 并发清除

对CPU数量有要求,并发的定义适合用户线程,然后并行的并不是等到 空间使用完了才收集,而是在过程中 书上说68%老年代的会出现收集,,jdk1.6启动阈值92%,另外一个是碎片合并的问题,可以参数控制

G1:

  • 并发并行,利用多CPU,缩短stop-the-world
  • 分带收集,能够独立管理所有代的 GC堆
  • 空间整合,从整体看是 标记–整理,局部看是复制算法,不会有碎片
  • 相对于CMS除了降低停顿时间,还能建立可预测的 停顿时间模型

G1之前 其它收集器进行搜收集范围是整个新生代或者老年代。G1将堆划分为多个大小相等的独立Region,然后手机过程不是整个新生代或老年代,而是根据每个Region的垃圾堆积的价值大小,说着只是划分而已然而实现并不简单2004年就发了论文,然后10年时间才开发出商用版,

回收步骤:

  • 初始标记
  • 并发标记
  • 最终标记 意思相当于上面的remark 并行,stop-the-world
  • 筛选回收 并行

理解GC日志

学会如何阅读GC日志,-XX:+PrintGCDetails,这个demo很快理解了,然后具体要用再查就OK

总结? 垃圾回收这一块 jvm也是带了很多参数可以在运行时使用, 分配会一定程度和GC收集器相关、受到影响

Eden?? TODO

大多情况,对象优先在Eden区域分配,当 Eden区域没有足够空间时,虚拟机将发起一次Minor GC

书上给了例子,4次分配,限制总空间和新生代空间,前三次能满足新生代空间,但第四次不能满足,同时vm没有找到可以回收的对象,为了满足分配保证机制,把前三次的送入老年代,然后第四次的在新生代分配,

可以设置大对象阈值,超过该阈值的直接进入老年代

Eden出生一次Minor到Survivor中,之后一次Minor GC年龄增长一岁,设置年龄界限参数,,然后还有一个同年龄的对象空间占用和达到一半会分入老年代的规则

担保分配过程 -> 一点统计参数+找可用剩余空间

4 章 虚拟机性能监控与故障处理工具

介绍jdk除了java和javac的其它工具,jdk1.5以下要专门加参数开启

可以搜SUN JDK 监控和故障处理工具命令行工具:

名称 主要作用
jps jvm process status tool,显示指定系统内所有的hotspot虚拟机进程
jstat jvm statistics monitoring tool,用于收集hotspot虚拟机各方面的运行数据
jinfo configuration info for java,显示虚拟机配置信息
jmap memory map for java,生成虚拟机的内存转储快照(heapdump文件)
jhat jvm heap dump browser,用于分析heapmap文件,它会建立一个http/html服务器 让用户可以在浏览器上查看分析结果
jstack stack trace for java ,显示虚拟机的线程快照

HSDIS JIT生成反汇编 =。= 哇哦 毕竟生成java可执行码直接看不懂,而汇编相对玩过C++和汇编的来说就容易看懂了

可视化工具:jconsole //可以通过线程观察等待的函数 甚至一些可以判定的死锁

//运行我写的一个项目然后是Eden在产生和被回收,但是survivor一直是100% 没有进入老年代的为何? 放久了有部分进入

visualVM 官方提供 的可视化可插件的

  • heap dump
  • Btrace动态日志跟踪 插入代码

5 章 调优案例分析与实战

用案例说明

案例1

从32位低硬件转移到高硬件,就是一个高访问,然后要从磁盘读数据的,分配了12GB/16GB的,而一次Full GC(同时回收年轻代 年老代,永久代满会导致class method元信息的卸载)需要14s的停顿,且频率十几分钟一次

文中举例64位的缺点:1.内存回收导致长时间停顿,无法dump【因为如果要dump 产生的文件大小能达到十多G 也无法分析,因为指针大小 对齐补白一般比32位内存使用更多

然后说更多的还是用32位虚拟机集群,缺点:1.注意并行资源竞争 2.均摊性能,3。32位虚拟内存上限,4.大量本地缓存

案例2

分布式 有一定共享的竞争型数据库,改用全局缓存后出现内存溢出问题。 memorydump出的数据显示很多NAKACK,这个是JBossCache保证各个包有效顺序及重发,也就是为了保证顺序性会在内存中保留并且网络不佳时会有大量重发,就会堆积以至溢出。【这个只是说了能分析到原因,没说解决方案,我猜大概 使用更大内存空间?

案例3

小的基于B/S电子考试系统,服务器不定时异常,但是把堆开到最大32位下1.6GB,也没有效果甚至异常更频繁了,然后加heapdump 没用,异常时没有dump文件,然后用jstat监视,gc不频繁,多个代都没压力。最后从系统日志中找到异常堆栈

Direct Memory不算如java的内存堆中,从输出的超过内存上界的报错看是direct 的allocate错误,虚拟机会对direct回收,但不会空间不足就触发像新生代老年代那样的回收,

socket 连接的receive 和send两个缓存区

案例4

外部命令导致系统缓慢,数字校园应用系统,并发压力测试时请求响应慢,CPU使用率高,,通过跟踪发现时linux的fork,而java中最多有线程产生没有进程产生,再溯源到Runtime.getRuntime().exec() ,,,解决方案是去掉该shell脚本 改为java api去获取信息。

案例5

服务器jvm进程崩溃,日志看是socke上http上的,问题溯源是 异步服务速度不对等 积累的等待线程socket

案例6

不恰当数据结构导致内存占用过大,感觉要对hashmap的实现了解

案例7

偶尔出现GC长达1min左右,但是gc的执行时间不长,但是准备时间长,然后监测到最小化时,资源管理中内存大幅度减少,最后断定是 windows把内存临时交换到磁盘里去了 加参数解决

实战调优

笔者用eclipse开发装了很多插件,启动要4-5min,全默认配置,作为对比,先统计了最初的启动 时间,垃圾回收耗时,full gc次数耗时,minor gc次数耗时,加载类,编译时间。

  • 升级jdk,升级jdk从宏观意义上统计意义上能带来一定的性能提升,这里1.5到1.6 update21上用力说是提升了15%,但是实际启动时间并没有效果

而且升级以后类加载时间更久了是原来的2倍,然后作者定位到???怎么定位,字节码验证部分耗时差距最大,然后加了一个禁用验证的参数,两个版本的时间才相近

取消验证后 原来15s的 1.5版本变为13s,1.6版本变为12s

然而升级以后 发生了内存溢出,visualvm检测到permgen的大小和最大值曲线重叠,解决方案是改配置文件的启动参数设置更大的永久代

这里的编译时间是指JIT HotSpot把java字节码编译为本地代码【但是如果把编译关了 换来的是执行时间更久

然后有不同优化程度的编译器,如果是长时间运行eclipse的话,那么用一个更多优化消耗更多编译时间的C2编译器,在运行速度中可以赚回来

最后优化GC,因为minor GC太频繁 耗时多,而minor GC会让多个线程跑到安全点等待回收,过多minor GC会导致不必要的挂起和恢复

minor通过参数增加eden大小,老年代回收日志显示 老年代在一次次扩容,然后 作者的做法一开始就设置好 减少自动扩展,然后这样设置以后 检测到的minor GC和full GC只有个位数次

然后观察到 老年代没有满过但还是有gc,,,原因是代码显式 System.gc(),通过参数禁用显式 GC,至此只需要7+秒

当前配置下eclipse进行代码编译中 老年代回收耗时长,CPU使用率最高才30%,通过参数指定 新生代和老年代的垃圾收集器,让老年代收集时间显著下降[实际上是CMS 和程序之间有并发时间 看上去停顿的时间降低显著]

该章总结 是通过不同部分运行日志 来分析运行的瓶颈,从而加参数修改。 知识依赖就是对 jvm运行时熟悉

6 类文件结构

代码编译结果 从 本地机器码 变为字节码?? 这说的不是物与物转换吧,是历史上的时间变为吧

【看了第一章 果然是时间上的 不是 物 转为 物

向java设计之初低头,说是最开始就拆分为 java语言规范 和 JVM规范,如现在有Groovy Scala等,大概就是 任何语言->编译器->.class字节码->在jvm上运行【突然感觉董哥之前的js on jvm好吊呀

以及 jvm 所支持的 大于 java所支持的

这里作者说有标准参考书《java 虚拟机规范(Java SE 7)》

二进制流,必要数据,无分隔符,类似C语言的伪结构体——无符号数和表

无符号数u1 u2 u4 u8,表_info格式结尾

类型的集合:同一类型但数量不定的多个数据时,前置容量计数器加若干个连续的数据项的形式

魔数与class文件版本

magic number,class文件头的4个字节,唯一作用确定该文件是否是一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来身份识别.

【例如gif jpeg文件头都有魔数

class的魔数为0xCAFEBABE

紧接着魔数的4个字节是版本号,我这边在linux下用vim+xxd可以看,想gui看的话可以ghex

[0123][45][67]
[魔数][次版本号][主版本号]

//即使代码没有变,低版本虚拟机也会拒绝高版本的class执行

再后面是常量池入口 显示u2类型 常量池容量计数值,从1开始计数,主要存放两大常量:字面量(文本字符串 声明 final 常量)和符号引用(类和接口的全限定名,字段的名称和描述符,方法名称和描述符),

然后常量池中每一个常量是个表,,类型挺多的。。到1.7有14种了?

[类型标识][按照类型的数据][类型标识][按照类型的数据]…

所以 方法 字段名会受到这里的限制,所以方法变量名 最大长度不能超过65535

然后有提到相应的工具 javap 带上-verbose参数,话说这个可以用来编译咯

其中可以看到 有部分常量没有在java代码中出现过,它们是自动生成的常量,如字段表 方法表 属性表

此处有表<常量池中的14种常量项的结构总表>

在常量池结束后是 两个字节访问标志(access_flags) 标识 类还是 接口 ,是否public ,是否被final,用的bit表示法,

类索引(this_class)父类索引(super_class)u2类型数据,接口索引集合(interfaces)一组u2类型的数据集合 描述具体实现了哪些类

除了java.lang.Object所有java类都有父类,

这两个索引用u2索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类型描述符常量,

查找到具体类的流程就是

this_class->CONSTANT_Class_info->CONSTANT_Utf8_info

【这一段将得好无聊啊23333,感觉都是按照 位置对应内容+(操作符+内容)的格式,

字段表集合,用于描述接口或者类中声明的变量。access_flags,name_index,descriptor_index,attributes_count,attributes

代码被编译存放在方法属性表集合中一个名为code的属性里面,,,然后java不能重载仅返回值不同的函数,但jvm标准的class文件中可以

属性方法表,code属性表

然后是javap 所得到的args_size 的值 在空时也为1,因为会隐式传入this

在字节码指令之后是 异常处理表集合, 格式 是 代码起始行 代码结束行 处理函数地址 要catch的类型

linenumbertable用于描述java源码行号与字节码行号之间的对应关系,可以用参数省去该表,但省去以后如果出现异常将无法直接定位源代码中所在行号

localvariableTable是描述栈帧中布局与原代码中变量之间关系,也是可以参数省去

以及sourceFile属性ConstantValue属性,InnerClasses,Deprecated 和Synthetic

这几页 感觉看了也记不住,而且也不可能说背,毕竟表结构和参数对应辣么多,但对大自轮廓有了印象

然后字节码简介 概括下来就是 基本仿照 真机器码的编码思想

作者对这些分类讲解。。。。。

局部变量加载到操作栈iload iload__n lload lload_n fload..

将一个数值从操作数栈存储到局部变量表,储存istore…lstore..

讲一个常量加载到操作数栈 bipush sipush ldc ldc_w ldc2_w aconst_null iconst_m1 iconst_i lconst_i

扩充局部变量访问索引指令wide

运算 基本按照 [变量类型][操作类型]来命名 ,比如 iadd isub ladd lsub

这里有个疑问它字节码有iinc 局部变量自增,那这个操作算原子的吗?

然后搜到说java中i++转换的字节码不是这个,然后我测了一下,局部变量还是会变成iinc的,但是全局的就不是,所以也就是说,iinc都只会在局部中出现咯,也就是并没有什么原子保证。

浮点数方面是IEEE 754,遵守浮点数值和逐级下溢运算规则,

转换指令 可以可以 向2=to,4=for低头 i2b,i2c等等

创建new[…] ,读取[…]load存储[…]store,

栈操作 pop… dup…复制+压入 swap

方法调用invoke[调用的配型] 返回[类型]return

看到这我还在想,为什么一个和寄存器有关的都没有,再想到java要一次编写,所以这是说靠jvm读字节码时再 和寄存器什么的相关咯

monitorenter和monitorexit支持,java中的synchronized同步

如果之前学过汇编,看这个就基本属于快速过掉,具体要查再查吧,这个的命名规则很是直接了,然后有部分 很明先汇编里没有的也大自看一看

7 虚拟机类加载机制

加载到内存 ,校验,转换解析和初始化,最终形成过程都是在程序呢运行期间完成的,

所以java是语言运行期类加载的特性

类生命周期

加载(Loading)->连接(Linking)(验证->准备->解析)->初始化->使用->卸载

规定:5种需要立即 初始化

  • new getstatic putstatic invokestatic字节码 且没有初始化时
  • java.lang.reflect 对类进行反射调用时 且没有初始化???????? 那这个对应的字节码呢 这划分得一脸懵逼,
  • 初始化类时,如果其父类没有初始化,需要先触发其父类的初始化
  • vm启动时,用户需要指定一个包含main方法的类,vm需要先初始话这个类,
  • 1.7版本的动态语言 中 REF_第一条 中的这些指令且没有初始化时

然后说有且只有这5种会,称作主动引用,其它是被动引用

静态字段只有直接定义这个字段的类才会被初始化

样例见书: 父亲类(初始化静态输出1,静态变量value),子类(初始化静态输出2), 调用样例输出 子类.value,因为value是父亲

惊了 我去测了一圈 然后才知道java里父类和子类的成员变量竟然都记录了的,比如A.x ,然后B是A的子类,有B.x,当你把一个B转化成A以后访问x只能访问到A.x,但是如果是方法的话,就算把B转成A访问还是会调用B.x,相当于C++中的自带virtual了

数组定义引用类不会触发, A[] arrA = new A[233];

final static被常量传播优化于编译阶段时,不会触发类的初始化

类加载过程

加载 验证 准备 解析 初始化 这5个阶段

加载

  • 通过类全限定名获取定义此类的二进制流
  • 将所代表静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据入口

因为规范相对宽松,也就成就了一些技术 从ZIP中读取如JAR EAR WAR,从网络中读取也就有了Applet,运行时计算生动态代理技术 java.lang.reflect.Proxy中,用了ProxyGenerator.generateProxyClass来为特定接口生成形式为,其它文件如jsp,数据库如sap

数组类

  • 类是引用类型 则按该引用类去加载
  • 不是引用类型如int[],Java虚拟机将会把数组标记为与引导类加载器关联。
  • 数组的可见性与它的组件类型的可见性一致,private/public

验证

确保字节流信息符合当前虚拟机需求,且不会危害虚拟机安全【本身相对于C/C++来说是安全的】,直接java代码到字节码已经是安全的,但是class文件也可以不由java编译产生,所以有必要检查class 如访问边界以外的数据啊这种操作。咦这样的意思是 可变边界的情况,就是由jvm来检查了吗?那比如A[]大小n,访问m,那比较这个是jvm做吗?A[3]访问x,由java直接写,但是转换出来的代码中会有检查吗?还是也丢给jvm做?再说A[x]访问x,在java中显示写x大小判断,还会被jvm检查吗?还是说有开关

说验证在整个加载系统中占了很大一部分

  • 文件格式验证 如 魔数开头,主次版本是否满足当前虚拟机,常量池是否有不被支持的常量,指向常量索引是否指向不存在的,utf8编码格式部分是否符合utf8编码 等等等等等
  • 元数据验证,是否符合java语言规范????不是jvm语言规范??,说是例如 类是否有父类,或者不符合规则的重载
  • 字节码验证(最复杂),通过数据流和控制流,分析语义是否合法,比如放入一个int,取出却按照long去除,跳转不会到方法体以外的字节码上,有增加一个StackMapTable来增加方法体基本快的描述 辅助验证
  • 符号引用验证,(在解析 阶段发生) 比如private protected 是否可以被当前类访问,引用通过字符串描述的全限定名是否能找到对应类

准备

分配内存和设置初始值,非final的都是设置为0,final的是变成constantVluae赋值,例如static int a=123;是赋值为0的,在()中的putstatic才赋值为123

解析

根据常量池符号引用替换为直接引用,相当于把名称描述换为具体的指针、偏移量什么的,只规定在具体的16个(见书)操作符号引用的字节码指令之前进行解析,

以及上面说的解析完后会有符号引用验证比如访问权限,

字段解析,如果本身有返回,否则去找父类,递归向上找

类方法解析/接口方法解析,类似原理,但会先检查class_index的位置是否对应正确,如类的应该对应类,接口的对应接口

初始化

加载的最后一步,执行构造器()方法的过程

这个是由编译器自动收集类中的所有类变量的赋值动作和静态语句块static{}合并产生的,顺序根据收集顺序决定

可以可以代码7-5有点意思哈

然后保证父类的clinit在子类的clinit前执行,也就是vm执行的第一个clinit是java.lang.Object的

保证多线程下一个类的clinit被正确加锁同步,多个线程同时初始化一个类,只有一个会执行clinit,也就是可能造成阻塞

类加载器

类的全限定名->描述此类的二进制字节流 放在jvm外部实现

层次划分、OSGi、热部署、代码加密等领域

同一个Class文件同一个虚拟机加载,但只要类加载器不同,类就必定不同

样例代码是,实现了一个动态获得Class的方法(区别于默认返回类,也就是这里是让jvm用一个不同的类加载器),然后用该方法获得A的Class 为B,但是 判断 A instanceof B返回却是false

对jvm来讲只有两种不同的,一个bootstrap类加载器(属于vm),其它所有类加载器(都由java语言实现 独立于虚拟机外部)

或者分为bootstrap,extension,application三种加载器

这里作者说,可以实验自己写一个rt.jar类库已有的重名java类,它可以正常编译,但永远无法执行

以上是”双亲委派模型”给了源码,也就是找 前面描述的加载过程,也就意味着,不同的类加载器就算用同样的代码但 对应的数据不同,所以无法分别加载

该模型可被破坏 对原来兼容(1.2以前),需要回调(JNDI服务),程序动态性(代码热替换 模块热不熟 OSGi(非双亲 更复杂网状 以Bundle+类加载器整体替换) )

8 虚拟机字节码执行引擎

运行时栈帧(Stack Frame)结构, 包括 局部变量表 操作数栈 动态链接 方法返回地址 额外附加信息

局部slot,再调用其它时才有可能gc,因为 这时才有可能复用,然后局部变量不能未赋初值就用

操作数栈从描述上就是字面意思,在概念上是独立的,,在一些具体的实现优化过程中,会用重叠来减少复制传递

返回方式 ret 正常的完成出口,异常出口 自动异常或者手动athrow

然后在实现虚拟机时,可以附加一些规范外的辅助信息

方法调用

也就是说invoke[…]对应的具体位置是哪,要经过解析把描述转化为具体的序号

静态分派:样例代码 A a=new B(),对一个 实例对象 进行父类转化,再调用重载函数,重载函数选择的是父类对应的接口

这里称A为静态类型在编译时期可知,B为实际类型,在运行时才可知,依赖静态类型来执行的叫做 方法重载

然后重载 的话,对参数类型有它的一个优先顺序

动态分配(Override): A a=new B(),调用方法f,也就是a.f(),jvm是如何定位的,通过javap得出的字节码,发现 样例代码中所有的都指向(静态类型)父类的函数 都是invokevirtual,然后这个过程是 先找实际类型,再逐个往上早父类的,其中只要成功找到就执行 再返回

静态单分派与动态多分派,这个和我上面自己做的实验一致了,就是 父类没被覆盖的也存在没有dead,父类自己调用就是自己的,从字节码上看父类子类调用基类的函数时,都是invokevirtual指向基类

补充 方法分派的几个例子

所以java目前是 静态多分派,比如父类子类invokevirtual指向的同一个,动态单分派,具体指向动态的

虚拟机动态分派实现:虚拟方法表vtable,以及虚拟接口方法表itable,emmmm就是每个类有个方法表中间存了方法入口地址233333

动态类型语言支持

静态类型 statically: 如果在编译时拒绝ill behaved程序,则是statically typed;

动态类型dynamiclly: 如果在运行时拒绝ill behaviors, 则是dynamiclly typed。

  • Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用
  • Reflection 包含信息更多更全重量级,MethodHandle轻量级,包含相关方法信息
  • Reflection for JAVA,MethodHandle for jvm

invokedynamic相对于MethodHandle来说一个是 字节码,一个是JavaAPI。。。然后invokedynamic面向的不是java是面向的其它语言生成的class

invokedynamic分派逻辑不是由虚拟机决定,而是程序员决定

解释执行引擎

大多数语言,词法分析->语法分析->抽象与法树(AST)

例如1+1基于栈是(可移植性)

iconst_1
iconst_1
iadd
istore_0

基于寄存器(性能)

mov 1 eax
add 1 eax

虚拟机可以把它转换

9 类加载及执行子系统的案例与实战

案例1 Tomcat等服务应用程序 正统的类加载器架构

因为 重用类库,安全性,支持JSP等的HotSwap,,所以都有隔离单独的类加载器。

下面是Tomcat为例,

  • /common目录中:类库可被Tomcat和所有的Web应用程序共同使用。
  • /server目录中:类库可被Tomcat使用,所有的Web应用程序都不可见。
  • /shared目录中:类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
  • /WebApp/WEB-INF目录中:类库仅仅可以被此Web应用程序使用,对Tomcat和其他Web应用程序都不可见。

Tomcat 自定义了多个类加载器,按照双亲委派模型实现

Spring相当于在上层,用户的在下层,模型特点是 职能向上加载不能向下。所以spring是通过 线程上下文类加载器来实现 访问并不在其加载范围内的用户程序

学习JEE规范,去看JBoss源码;学习类加载器,就去看OSGi(Open Service Gateway Initiative)源码

OSGI从层次依赖变成平级依赖,可以import 或者 export,哇 这不是 typescript那些吗,难道typescript灵感来源于此

OSGI可以热插拔,当一部分更新时,可以只停用,重新安装然后启用程序中一部分

运行时网状结构

但循环依赖可能出现死锁,

逆向移植(把代码 放到更老的版本上去执行)Retrotranslator,jvm层面的做不到,但是可以 把java->jvm层面的做不少

远程执行功能

  • 不依赖JDK版本1.4-1.7都能执行
  • 不改变原有服务端程序部署,不依任何第三方库
  • 不侵入原有程序
  • 临时代码需要直接支持JAVA
  • 临时代码具备一定自由度,不需要依赖特定的库或接口
  • 执行结果 包括输出 异常 能返回到客户端

惊了 说只要5个类+250行代码,1个半小时,,,对不起 我不会JAVA

问题

  1. 如何编译提交到服务器的java代码
  2. 如何执行编译之后的java代码
  3. 如何收集java代码的执行结果

问题1 方案A 传代码,使用tools.jar 来编译,或者javac,缺点是服务端需要tools。 方案B 在客户端编译,传字节码到服务端

问题2 让类加载器加载这个类生成Class对象,然后又反射调用某个方法就可以了,执行完后应当被卸载回收掉

问题3 直接在执行的类中把对System.out的引用替换为准备的PrintSteam,,

实现见 Remote-classload 仓库

  1. 本地运行劫持err和out输出的HackSystem
  2. 用户class->调用JavaClassExecuter.execute(byte[])
  3. ClassModifier+ByteUtils 两个类 把传入的class中 所有调用java/lang/System的换成你所运行的HackSystem
  4. 运行HotSwapClassLoader(),加载修改过后的class
  5. 运行加载了的class
  6. 其中有错误或者输出,则由HackSystem输出

10 早期(编译器)优化

  • 前端编译器(java->class): ECJ,Java
  • JIT(Just In Time Compiler)字节码->机器码: HotSpot VM的C1 C2编译器
  • AOT(Ahead Of Time Compiler): GNU Compiler for the Java(GCJ)、Excelsior JET

javac 几乎没有优化,对Java语言编码过程的优化措施来改善程序员编码风格和提高编码效率

Javac 使用Java编写和少量C语言

源码位置javac:

sudo apt install openjdk-8-source
ls /usr/lib/jvm/java-8-openjdk-amd64/src.zip
解压它 找 sun/tools/javac中

eclipse 默认禁止一些代码访问规则,所以要调试javac的话需要 把这个开关关闭

javac 的3个过程

  • 1解析与填充符号表过程
  • 2插入式注解处理器的注解处理过程
  • 3分析与字节码生成过程

其中1、2 这两步可以循环 ->1->2-1->2->1->2->3->

javac动作入口com.sun.tools.javac.main.JavaCompiler,上面3个步骤集中在类的compile()和compile2()方法中

  1. 词法语法分析 parseFiles()完成

词法分析com.sun.tools.javac.parser.Scanner类实现 源代码字节流转变为Token集合

语法分析 com.sun.tools.javac.parser.Parser产生出com.sun.tools.javac.tree.JCTree类 是根据Token序列构造抽象语法树的过程AST,每个节点表示程序代码中的一个语法结构,例如Eclipse 有 AST View插件可以看

  1. 填充符号表 enterTrees()方法

符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,也就是Key-Value模式(有的是有序 有的是树状 有的是哈希 栈结构等)

在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码

填充过程com.sun.tools.javac.comp.Enter类实现,过程出口是一个待处理表(To Do List)包含了每一个编译单元的抽象语法树的定级节点, 以及 package-info.java(如果存在的话)的顶级节点。

  1. 注解处理器(Annotation) 规范JSR-269

可以看做插件,可以读取修改 抽象语法树 元素,这个过程执行后 编译器回到解析及符号填充表重新处理,直到所有插入式注解都结束

插入式注解处理器 初始化过程initProcessAnnotations(),执行过程processAnnotations()判断是否还有注解如果有com.sun.tools.javac.processing.JavacProcessingEnvironment 类的doProcessing()方法生成一个新的JavaCompiler对象对编译的后续步骤进行处理。

  1. 语义分析与字节码生成

语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。[这里举例是不同类型做加法]

  • 标注检查 attribute() 比如类型 变量赋值类型的匹配,1+2折叠成3,具体的类是com.sun.tools.javac.comp.Attrcom.sun.tools.javac.comp.Check
  • 数据及控制流分析 flow() 上下文,比如是否初始化局部变量,方法每条路径是否都有返回值,异常有没有被正确处理,具体由com.sun.tools.javac.comp.Flow处理

变量的不变性仅仅由编译器在编译期间保障,这里的例子是是否有final最终的class文件是没有区别的

  1. 解释语法糖

java低糖语法 如 泛型、变长参数、自动装箱/拆箱 等。这些语法被编译器转换为 jvm支持的更基础的指令。解释过程由 desugar() 方法触发,在 com.sun.tools.javac.comp.TransTypes 类和com.sun.tools.javac.comp.Lower 类中完成。

  1. 字节码生成

com.sun.tools.javac.jvm.Gen类来完成,

这个阶段把 init和clinit添加到语法树中,也就是生成构造器工作,以及一些代码替换工作如把字符串的加操作替换为 StringBuffer 或 StringBuilder的 append() 操作等

关于语法糖的背后

泛型与类型擦除

也就是由C++的模板Template思想起,【之前没看过java的时候一个面试我C++的问我C++中的泛型我一脸懵逼

老版本是强行转化,所以能不能转的风险交给程序员,

List<int>List<String>是两个不同类型,在运行期间有不同的虚方法表和类型数据,称为类型膨胀

然后java中只有java中存在泛型,class文件中 已经都替换为原生类型

【举例了两个 只接受List参数的函数 来进行编译和反编译,第二个例子下采用了不同类型返回值】这里说因为 采用类型擦除,只靠List内的类型不同已经不能区分,第二个例子 只有Sun JDK 1.6能编译,并且违反了返回值不参与的原则?

测了一下报错error: name clash: f(List<String>) and f(List<Integer>) have the same erasure

真实原因是不同返回值以后, class文件下,擦除了类型的函数具体描述才能不同,才能共存于同一个class中。当然也只有Sun JDK 1.6支持

下面一段没看懂Signature部分是啥 签名?感觉大自意思是 有的程序需要识别你擦除的部分,所以还需要额外的影响和改动。以及,从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。

1
由于 Java 泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都有可能对原有的基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。因此,JCP 组织对虚拟机规范作出了相应的修改,引入了诸如 Signature、LocalVariableTable 等新的属性用于解决伴随而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别 49.0 以上版本的 Class 文件的虚拟机都要能正确地识别 Signature 参数。

自动装箱、拆箱与遍历循环(Foreach)

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4); // 自动装箱
// 如果在 JDK 1.8 中,还有另外一颗语法糖
// 能让上面这句代码进一步简写成 List<Integer> list = [1, 2, 3, 4];
int sum = 0;
for (int i : list) { // 循环遍历+自动拆箱 变成迭代器每一个元素取出 并 转换为int类型
sum += i;
}
System.out.println(sum);
}

上面包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数 5 种语法糖

错误用法: 完了要是面试问下面这个输出 我就凉凉: 包装类的“==” 运算在不遇到算术运算的情况下不会自动拆箱,以及它们 equals() 方法不处理数据转型的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d);// 比值
System.out.println(e == f);// 超过[-128,127]比地址
System.out.println(c == (a + b));// 被自动装箱 转换==
System.out.println(c.equals(a + b));// 自动装箱equals 比 值&类型
System.out.println(g == (a + b)); // 自动装箱==
System.out.println(g.equals(a + b)); // 自动装箱equals 比 值&类型
}

emmmm感觉java需要手工加类型转换[或者编译检测有参数选项可以打开?

条件编译 解除语法糖阶段(com.sun.tools.javac.comp.Lower类中完成)

如C++中 #ifdef,java可以用if+常量的方式实现条件编译,2333,66666,,, 其它判断没有这个能力

除了上面的一些语法糖还有 内部类、枚举类、断言语句、对枚举和字符串(在 JDK 1.7 中支持)的 switch 支持、try 语句中定义和关闭资源(在 JDK 1.7 中支持)等

插入式注解处理器 实验

使用 JSR-296 中定义的插入式注解处理器 API 来对 JDK 编译子系统的行为产生一些影响。

javac主要在于看 程序写得对不对,而少的去看程序写得好不好。有辅助工具 CheckStyle、FindBug、Klocwork等,基于java源码或字节码校验

本实验是利用 注解处理器API来编写一款拥有自己编码风格校验工具 NameCheckProcessor

功能:仅仅对Java程序明明进行检查

  • 类(或接口):符合驼式命名法,首字母大写。
  • 方法:符合驼式命名法,首字母小写。

字段:

  • 类或实例变量:符合驼式命名法、首字母小写。
  • 常量:要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。

实现

我们实现注解处理器的代码需要继承抽象类 javax.annotation.processing.AbstractProcessor

这个类只有一个必须覆盖的abstract方法process()

这个函数在javac执行注解处理器代码时要调用的过程,

https://docs.oracle.com/javase/7/docs/api/javax/annotation/processing/AbstractProcessor.html

上面这个链接的example

1
2
3
4
abstract boolean

process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
Processes a set of annotation types on type elements originating from the prior round and returns whether or not these annotations are claimed by this processor.

第一个参数 “annotations” 中获取到此注解处理器所要处理的注解集合,从第二个参数 “roundEnv” 中访问到当前这个 Round 中的语法树节点,每个语法树节点在这里表示为一个 Element。

在 JDK 1.6 新增的 javax.lang.model 包中定义了 16 类 Element,包括了 Java 代码中最常用的元素,如:

  • 包(PACKAGE)
  • 枚举(Enum)
  • 类(CLASS)
  • 注解(ANNOTATION_TYPE)
  • 接口(INTERFACE)
  • 枚举值(ENUM_CONSTANT)
  • 字段(FIELD)
  • 参数(PARAMETER)
  • 本地变量(LOCAL_VARIABLE)
  • 异常(EXCEPTION_PARAMETER)
  • 方法(METHOD)
  • 构造函数(CONSTRUCTOR)
  • 静态语句块(STATIC_INIT,即 static{} 块)
  • 实例语句块(INSTANCE_INIT,即 {} 块)
  • 参数化类型(TYPE_PARAMERTER,既泛型尖括号内的类型)
  • 未定义的其他语法树节点(OTHER)

除了 process() 方法的传入参数之外,还有一个很常用的实例变量 processingEnv,它是AbstractProcessor 中的一个protected 变量,在注解处理器初始化的时候(init() 方法执行的时候)创建,继承了 AbstractProcessor 的注解处理器代码可以直接访问到它。它代表了注解处理器框架提供的一个上下文环境,要创建新的代码、向编译器输出信息、获取其他工具类等都需要用到这个实例变量


返回值 true/false表示是否对代码进行更改,如果未更改,javac也就不会再处理?

所有的运行都是单例 这里要实现的 功能仅是检查,所以始终返回false

注解处理器NameCheckProcessor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 可以用 "*" 表示支持所有 Annotations  
@SupportedAnnotationTypes("*")
// 只支持 JDK 1.6 的 Java 代码
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class NameCheckProcessor extends AbstractProcessor{

private NameChecker nameChecker;

/**
* 初始化名称检查插件
*/
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
nameChecker = new NameChecker(processingEnv);
}

/**
* 对输入的语法树的各个节点进行名称检查
*/
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
for (Element element : roundEnv.getRootElements()) {// 获取当前Round的每一个 RootElement
nameChecker.checkNames(element); // 检查
}
}
return false;
}
}

具体检查代码太长就不贴全了,贴几个主要的函数

1
2
3
public NameChecker(ProcessingEnvironment processingEnv) {  
this.messager = processingEnv.getMessager(); // 之后可以调用messager.printMessage()来输出 信息比如 不符合规则的具体内容和具体规则
}

检查部分继承了ElementScanner6<Void, Void>

之后就是复写这个类的对应函数,对于访问element的部分根据文档 调用函数得到element的名字 和element的类型,对于名字判断就是if case的事情

它通过一个继承于 javax.lang.model.util.ElementScanner6 的 NameCheckScanner 类,以 Visitor 模式来完成对语法树的遍历,分别执行 visitType()、visitVariable() 和 visitExecutable() 方法来访问类、字段和方法,这 3 个 visit 方法对各自的命名规则做相应的检查,checkCamelCase() 与 checkAllCaps() 方法则用于实现驼式命名法和全大写命名规则的检查。

这一章 我有复制较多的原书上的话,我认为它们已经概括总结得不太能再简略了,就直接用了,也把不是很必要的代码省去

11 晚期(运行期)优化

解释器+编译器(包含C1 client 编译器 C2 server编译器)并存

C1 简单可靠优化 有必要时加入性能监控逻辑

C2 耗时较长的优化,不可靠的激进优化

触发从解释器到编译器: 采样热点(一定周期发现某个方法经常出现在栈上),计数热点(阈值+计数器判定)

触发编译器到解释器:如动态的类

HotSpot 采用的是计数器热点探测, 包含两类计数器 (方法调用计数器,回边计数器) 都有各自的阈值

除了单纯的计数 还有一个根据时间的热度半衰期 23333 学习于自然之中

回边计数器是为了统计循环体代码执行的次数

client下 回边计数阈值 = 调用计数阈值xOSR比率 (On Stack Replace Rercentage) / 100

server下 回边计数阈值 = 调用计数阈值xOSR比率 (On Stack Replace Rercentage)xjiesh / 100

[嗯!!! 终于这个代码是hpp后缀的了 吼呀

只要编译还没完成 都是按照解释器继续执行 而不是等待

但可以 加参数禁用后台编译 则是 等待形编译

第一个阶段 一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR

在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation,LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到更高效的代码表示形式。

最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码.几乎能达到 GNU C++ 编译器使用 -O2 参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等

还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的控制检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化了)等。另外,还可能根据解释器或 Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等


-XX: +PrintCompilation要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来

带有 % 的输出是由回边计数器触发的OSR

-XX: +PrintInlinint 要求虚拟机输出方法内联信息

优化技术可以看Sun官方的Wiki,也可以搜索 即时编译器优化技术一览 ,向这么多方法低头

示例:

  1. 类的get方法,如果用内联优化,就是把get的方法变成点参数,是可以减少栈帧的开销,又能为其它优化打好基础

  2. 访问同一个类的参数,可以消除冗余的访问

  3. 把没有意义的进行复写传播 以及无用的代码消除 如 y=x;z=y;k=z+x直接就变成k=x<<1

嗯。。。以前编译原理课都有讲过上面列举的

  • 语言无关的经典优化技术之一:公共子表达式消除。表达式计算过 在没有变化的情况下 再次计算
  • 语言相关的经典优化技术之一:数组范围检查消除。消除绝对没有越界的数组访问 和安全性相呼应【如果不确定是否越界还是要做的】
  • 最重要的优化技术之一:方法内联。
  • 最前沿的优化技术之一:逃逸分析。

除了如数组边界检查优化这种尽可能把运行期检查提到编译期完成的思路之外,另外还有一种避免思路——隐式异常处理,Java 中空指针检查和算术运算中除数为零的检查都采用了这种思路

1
2
3
4
5
if (foo != null) {
return foo.value;
else {
throw new NullPointException();
}

在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。

1
2
3
4
5
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}

虚拟机会注册一个 Segment Fault 信号的异常处理器(伪代码中的 uncommon_trap()),这样当 foo 不为空的时候,对 value 的访问是不会额外消耗一次对 foo 判空的开销的。代价就是当 foo 真的为空时,必须转入到异常处理器中恢复并抛出 NullPointException 异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当 foo 极少为空的时候,隐式异常优化是值得的,但假如 foo 经常为空的话,这样的优化反而会让程序更慢,还好 HotSpot 虚拟机足够 “聪明”,它会根据运行期收集到的 Profile 信息自动选择最优方案。


方法内联

为了解决虚方法的内联问题,Java 虚拟机设计团队想了很多办法,首先是引入了一种名为 “类型继承关系分析”(Class Hierarchy Analysis,CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。

编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。如果遇到虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个 “逃生门”(Guard 条件不成立时的 Slow Path),称为守护内联(Guarded Inlining)


逃逸分析(Escape Analysis)

它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术

证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化

  • 栈上分配(Stack Allocation):Java 虚拟机中,在 Java 堆上分配创建对象的内存空间几乎是 Java 程序员都清楚的常识了,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
  • 同步消除(Synchronized Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  • 标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java 中的对象就是最典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的告诉寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。

关于逃逸分析的论文在 1999 年就已经发表,但直到 Sun JDK 1.6 才实现了逃逸分析,而且直到现在这项优化尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。

目前发展并不够成熟


Java与C++编译器对比

  1. 即时编译器运行占用的是用户程序的运行时间, 因此即时编译器 不敢大量引入优化技术
  2. JAVA是动态的类型安全,因此有很多安全方面的检查开销 是不会彻底的被编译器优化掉的
  3. 虽然java没有virtual但虚方法使用频率远大于C++,这方面的优化难度也更大
  4. 动态语言,编译器难以看清全貌,职能运行时撤销或者重新进行一些优化
  5. C++有很多在栈上,而java很多在堆上,垃圾回收效率

【可以 ,,,基本是java的劣势,但是 并不是说不如,而是说有 安全 动态性等所作出的 代价回报

有java更容易分析的 别名分析 Java 的类型安全保证了在类似如下代码中,只要 ClassA 和 ClassB 没有继承关系,那对象 objA 和 objB 就绝不可能是同一个对象,即不会是同一块内存两个不同别名。

void foo(ClassA objA, ClassB objB) {
objA.x = 123;
objB.y = 456;
// 只要 objB.y 不是 objA.x 的别名,下面就可以保证输出为 123
print(objA.x);
}

但是C++有union之类的???

另外的话 JAVA能够在 运行期间有调用频率预测 分支频率预测 等动态的运行红利

个人感觉就是 有大量相似、相同的优化技术,但因为优化的源和目标结果并不相同会有一些差异。差异可以概括为用性能换得了更多的可控和安全性(比如 安全性 是相对于同样精力编码来说的?因为C++要强行写应该也能做出安全保障吧?),而优化技术可以看做基本都是那么些方法

12 Java内存模型与线程

震惊 希望计算机能同时做其他事的原因竟然是 io等其它速度太慢,把这些原本的等待时间 压榨出来

java 带有的并发逻辑 更加容易让程序员关注业务逻辑,而不是协调

现代 加上cache,但面临一致性问题,为了解决一致性缓存问题,协议有MSI MESI MOSI Synapse Firefly Dragon Protocol


内存模型JMM(java memory model),用于让在不同的硬件和不同的操作系统上达到效果一致[相对于C++使用物理硬件来说]

需要严谨 & 宽松

主内存与工作内存 之间的交互协议

  • lock 作用于主内存的变量,它把一个变量标识为一条线程独占的状态
  • unlock 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中
  • load 作用于工作内存变量,把read 操作从主内存中得到的变量值放入工作内存的变量副本中
  • use 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,虚拟机需要使用变量的值得字节码指令时,将会执行这个操作
  • assign 作用于工作内存变量 把执行引擎接收到的的值付给工作内存的变量,
  • store 作用工作内存变量,把值传送到主内存方便以后write操作使用
  • write 写入 作用于主内存的变量,它把store 操作从工作内存中得到的变量的值防御主内存中

流线可以看作,【似乎下面的意思这些都原子 不需要lock,只有程序员想额外保证原子的再lock?

read(主内存到线程内存) -> read(从线程内存到变量副本中) -> use(使用)

assign(写) -> store(变量副本到从线程内存) -> write(写入主内存)

  1. 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  2. 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  6. 如果对一个变量执行了lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前, 需要重新执行load或assign操作初始化变量的值。
  7. 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其它线程锁定住的变量。
  8. 对一个变量执行unlock操作之前,必须把此变量同步回主内存中(执行store、write操作)。

以及Volatile的规定,当一个变量定义为volatile之后,它将具备两种特性:

  1. 保证此变量对所有线程的可见性;普通变量(没被volatile修饰)做不到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
  2. volatile变量不是线程安全的;volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。 所以需要同步保护(synchronized或 java.util.concurrent)

这里是说用 lock add $0x0 (%esp)的技巧 来制作重排序屏障 理论上是比锁快

对于long和double型变量的特殊规则

要求上面lock unlock等8个操作都是原子性, 但允许没有被volatile的64位类型读写划分为两次32位操作,也就是64位 可以不保证load store read write的原子性【 所以可能读到半个变量,虽然标准中没有原子规定,但是建议的虚拟机实现最好是原子的实现

  • 原子性: 内存模型直接保护 就是除了lock和unlock,如果用户需要更多原子性保护可以用lock和unlock,对于更高层次,虚拟机提供了monitorenter和monitorexit来隐式使用两个操作,再向上说就是java中 的synchronized
  • 可见性:volatile的特殊规则保证了新值能够立即 同步到主内存
  • 有序性: 线程内是有序的 volatile(包含禁止重排序的语义) synchronized(包含一个变量同一时刻只允许一条线程对其lock)

顺序性

  • 程序次序规则:根据控制流来说 控制流前面的代码先执行
  • 管程锁定规则 一个unlock先于同一个锁的lock
  • volatile 写先于(后面的)读
  • 线程启动规则,Thread对象的start()方法先行发生于次线程的每一个动作
  • 线程中断规则: 线程中断调用 先于 中断检测
  • 对象终结规则, 初始化先于finalize()
  • 传递性: A先于B B先于C 则 A先于C

在满足上面的无同步手段保障时 可以随意重排序,线程不安全的例子:

1
2
3
4
5
6
7
8
private int value = 0;
public void setValue(int value){
this.value = value;
}

public int getValue(){
return value;
}

上面规则都不适用,但是缺无法确定不同线程同时执行时这里的返回结果,所以它是线程不安全的

修复问题 解决方案

  1. getter/setter都定义为synchronized方法 可以套用管程规则
  2. 把值定义为volatile变量

java提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行start()且未结束的java.lang.Thread类的实例就代表了一个线程

实现方法主要有3种:

  • 内核线程实现(Kernel-Level Thread) 直接让内核调度器调度,层级关系CPU-线程调度器-KLT-LWP(Light Weight Process)-程序使用
  • 用户线程实现 (User Thread), 完全建立在用户控件的线程库上,系统内核不能感知存在的,可以降低 用户态和内核态切换的消耗,java和ruby曾经用过最终放弃了?
  • 用户线程实现 加 轻量级进程混合实现,solaris HP-UX提供了相应的模型实现,和内核线程实现比起来是在LWP和程序使用之间 用UT

线程调度 协同或者抢占式

java设置了10个级别的线程优先级,windows 有7种,solaris有2^32种。。。。 windows还有一个 优先级推进器6666

5个状态

  • 新建
  • 运行
  • 等待(无限等待 时间等待)
  • 阻塞 相对于等来说就是有一个明确的阻塞目标???
  • 结束

13 线程安全与锁优化

你看这个本书都说 google都找不到“线程安全”的标准定义了 :-) 面试官还一天问个不停

java中的线程安全

  1. 不可变 -一定安全 只要被正确构建 final 以及例如java.lang.String 枚举类型等
  2. 绝对线程安全,不管运行时环境如何,调用者都不需要任何额外的同步措施,,,,这里说理论上没有 绝对的库还能向上用,因为你上层的实现可能是对一个线程安全的进行分步操作,,,,
  3. 相对线程安全,,通常意义的线程安全
  4. 线程兼容,,,本身不是线程安全,但可以通过 同步手段让它们达到线程安全
  5. 线程对立,,,无论如何都无法达到

线程安全的实现

  1. 互斥同步,临界区 互斥量 信号量 都是主要手段,。。。。互斥是因 同步是果,互斥是方法 同步是目的

synchronized 同步 对同一个线程来说是可以重入的

也可以使用java.util.concurrent 包中的重入锁(ReentrantLock),相对于synchronized多了 三个功能,等待可中断,公平锁,锁绑定多个condition,,,然后性能说 单核多线程,线程数量上去后 ReentrantLock表现更好jdk1.5,之后版本优化得差不多,所以能直接sync实现也就不用麻烦

  1. 非阻塞同步 emmm这就是类似乐观锁 而上面就是悲观锁

需要硬件支持

  • 测试并设置
  • 获取并增加
  • 交换
  • 比较并交换 CAS
  • 加载链接、条件存储
  1. 无同步方案

只要保证可重入,没有因果关系,也可以实现

锁优化

高效并发是jdk1.5到1.6的一个重要改进,如

  • 自旋锁与适应性自旋,相当于说 如果锁的交替很快,那么自旋锁实际就是通过帮你多等一会,来节省内核态切换的开销,默认等待次数10次,然后自旋锁再自适应次数,就能更准确的预测等待次数。
  • 锁清除,清除无意义的锁 也就是前面逃逸分析里提到过的
  • 锁粗化 原则上是同步块越小越好,但连续的拿锁放锁有较大不必要的性能消耗
  • 轻量级锁 使用CAS 相对于操作系统的互斥量的锁,并不是代替操作系统互斥量锁,,,emm相当于先简单的读写尝试 成功则运行,不然膨胀为重量级锁 允许多个线程获得 但不允许竞争
  • 偏向锁(Biased Locking) 和轻量级锁类似,但它向上升级就是轻量级锁 只允许一个线程获得

总结

感觉看jvm最好先学C++一遍再看一遍操作系统,反而java不怎么需要看,感觉作者是站在你已经会C++的程度上来写的这本书

本Markdown文档依赖和感谢

《深入理解Java虚拟机》盗版pdf XD

Ubuntu 18.04 bionic && java-8-openjdk-amd64 && 各种工具了

董哥的指路

网上各个对该书的图文完全复制的博客,[以及chrome的去广告插件和自定义css插件

google+stackoverflow对相关问题的回答

github上搜到的书中所述的相关代码仓库

在线java编辑编译器https://www.tutorialspoint.com/compile_java_online.php

[排序根据贡献从前到后]