《深入理解Java虚拟机》学习笔记

《深入理解Java虚拟机》学习笔记

一、走近Java

JDK(Java Development Kit):包含Java程序设计语言,Java虚拟机,JavaAPI,是用于支持 Java 程序开发的最小环境。

 

JRE(Java Runtime Environment):包含Java SE API 子集,Java 虚拟机,是支持Java程序运行的标准环境。

 

Java的优点:

  (1)提供了相对安全的内存管理和访问机制,避免了绝大部分的内存泄露和指针越界问题。

  (2)实现了热点代码检测和运行时编译及优化。

 

Java的一些历史:

  98年出了JDK1.2,java技术体系被拆分为3个方向:

  J2SE:面向桌面应用开发

  J2ME:面向手机等移动终端

  J2EE:面向企业级开发

 

  99年发布的HotSpot虚拟机成为了JDK1.3之后的默认虚拟机。

  JDK1.5在java语法易用性上做出了非常大的改进:自动装箱,泛型,枚举,可变长参数,foreach循环,动态注解等。还提供了并发包。

  从JDK1.5开始,公开版本号改为JDK5 JDK6,JDK1.5这样的名称只有在程序员内部使用的开发版本号才继续沿用。

  从JDK1.6开始,终结了J2EE这样的命名方式,采用Java EE 6,Java SE 6这样的命名方式。

    Java 7中,switch语句块中允许以字符串作为分支条件

 

  Java 8中,将提供Lambda支持,将会极大改善目前java不适合函数式编程的现状。

  函数式编程接近自然语言,易于编程,适合并行运行。

 

  目前Java程序运行在64位的虚拟机上需要付出较大的额外代价,许多企业应用仍然选择使用虚拟集群等方式继续在32位虚拟机中进行部署。

 

二、Java内存区域

  C/C++,需要担负每一个对象生命开始到终结的维护责任。

  java在虚拟机自动内存管理机制的帮助下,不需要为每一个对象去写delete/free代码,不容易出现内存泄露,内存溢出问题。

 

运行时数据区域:

  包括程序计数器,虚拟机栈,本地方法栈,方法区,堆

 

程序计数器(线程私有):

  是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。

  正在执行java方法的话,计数器记录的是虚拟机字节码指令的地址。如果还是Native方法,则为空。

  这个内存区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError情况的区域。

 

Java虚拟机栈(线程私有)

  也是线程私有的。

  每个方法在执行的时候会创建一个栈帧,存储了局部变量表,操作数栈,动态连接,方法返回地址等。

  每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。

  通常所说的栈,一般是指虚拟机栈中的局部变量表部分。

  局部变量表所需的内存在编译期间完成分配。

  如果线程请求的栈深度大于虚拟机所允许的深度,则StackOverflowError。

  如果虚拟机栈可以动态扩展,扩展到无法申请足够的内存,则OutOfMemoryError。

 

本地方法栈(线程私有)

  和虚拟机栈类似,主要为虚拟机使用到的Native方法服务。

  也会抛出StackOverflowError和OutOfMemoryError。

 

Java堆(线程共享)

  被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例。

  堆可以按照可扩展来实现(通过-Xmx和-Xms来控制)

  当堆中没有内存可以分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

 

方法区(线程共享)

  被所有线程共享的一块内存区域。

  用于存储已被虚拟机加载的类信息,常量,静态变量等。

  这个区域的内存回收目标主要针对常量池的回收和对类型的卸载。

  当方法区无法满足内存分配需求时,则抛出OutOfMemoryError异常。

  在HotSpot虚拟机中,用永久代来实现方法区,将GC分代收集扩展至方法区,但是这样容易遇到内存溢出的问题。

  JDK1.7中,已经把放在永久代的字符串常量池移到堆中。

  JDK1.8撤销永久代,引入元空间。

 

 

  方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收掉,判定条件是非常苛刻的。

  在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。

  这类场景除了上面提到的程序使用了 GCLib 字节码增强外,常见的还有:大量 JSP 或动态产生 JSP 文件的应用( JSP 第一次运行时需要编译为Java 类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

 

 

运行时常量池:

  是方法区的一部分,用于存放编译期生成的各种字面量和符号引用(类加载后的常量池信息),虚拟机加载Class后把常量池中的数据放入到运行时常量池中。

 

  Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。

  这里所说的常量包括:基本类型包装类(包装类不管理浮点型,整形只会管理-128到127)和String(也可以通过String.intern()方法可以强制将String放入常量池) 

 

  当常量池无法再申请到内存时,则抛出OutOfMemoryError异常。

 

直接内存:

  不是运行时数据区的一部分,但也可能抛出OutOfMemoryError异常。

  在JDK1.4中新加入的NOI类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数直接分配堆外内存,

然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

 

对象的创建:

  指针碰撞:所有用过的内存在一边,空闲内存在另一边,中间放着一个指针作为分界点的指示器,

分配内存就是把指针往空闲内存那边挪一段与对象大小相等的距离。在使用Serial,ParNew等收集器,

(也就是用复制算法,标记-整理算法的收集器),分配算法通常采用指针碰撞。

  空闲列表:虚拟机维护一个列表,记录哪些内存是可用的,分配的时候从列表中找到一块足够大的空间划分给对象,并更新列表。

使用CMS这种基于标记-清除算法的收集器,通常用空闲列表。

 

  内存分配完之后,虚拟机要将分配到的内存空间都初始化为零值(不包括对象头),保证了对象的实例字段在Java代码中可以不赋初始值就直接使用。

  

 

对象的内存布局:

  对象在内存中可分为3个部分,对象头,实例数据,对齐填充。

  对象头的第一部分用于存储对象自身的运行时数据,如对象的哈希码,GC分代年龄,锁状态标志,线程持有的锁等。

  另一部分是类型指针,即对象指向它的类元数据的指针,通过这个来确定这个对象是哪个类的实例。

  实例数据是对象真正存储的有效信息。

 

对象的访问定位:

  程序要通过栈上的reference数据来操作堆上的具体对象。对象的访问方式有使用句柄直接指针

  使用句柄:java堆会划分一块内存作为句柄池,reference中存的是对象的句柄地址,而句柄中包含了对象的实例数据的地址和类型数据的地址(在方法区)

优点:对象被移动,reference不用修改,只会改变句柄中保存的地址。

  使用直接指针:reference中存的是对象的地址,对象中分一小块内存保存类型数据的地址。优点:速度快。

 

 三、垃圾收集器与内存分配策略

   垃圾回收主要针对java堆和方法区。

对象存活判定算法:

  引用计数算法:每当一个地方引用一个对象,计数器就加1,引用失效的时候,计数器就减1。当计数器为0的时候就表示对象不会再被使用。

缺点:难以解决对象之间相互循环引用的问题。

  可达性分析算法(主流):用过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,

当一个对象到GC Roots没有任何引用链相连时,则此对象是不可用的。

 

 

在 Java 语言里,可作为 GC Roots 的对象包括下面几种:

 

  虚拟机栈(栈帧中的本地变量表)中引用的对象。

  方法区中的类静态属性引用的对象。

  方法区中的常量引用的对象。

  本地方法栈中 JNI(Native 方法)的引用对象。

 

 

  不过那些发现不能到达 GC Roots 的对象并不会立即回收,在真正回收之前,对象至少要被标记两次。

  当第一次被发现不可达时,该对象会被标记一次,同时调用此对象的 finalize()方法(如果有);在第二次被发现不可达后,对象被回收。

  利用 finalisze() 方法,对象可以逃离一次被回收的命运,但是只有一次。逃命方法如下,只要在 finalize()方法中让该对象重引用链上的任何一个对象建立关联即可。

  而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

 

四大引用

  jdk1.2之后,将引用分为强引用,软引用,弱引用,虚引用,4种强度依次逐渐减弱。

  强引用:  强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,

Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。 

  软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。

只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。比如网页缓存、图片缓存等。使用软引用能防止内存泄露,增强程序的健壮性。

提供了 SoftReference 类实现软引用。

  弱引用:弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。

在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

提供了 WeakReference 类来实现弱引用。

  虚引用:“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。

如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

为一个对象关联虚引用的唯一目的,就是希望在这个对象被收集器回收时,收到一个系统通知。

虚引用主要用来跟踪对象被垃圾回收器回收的活动。提供了 PhantomReference 类来实现虚引用。

 

回收方法区:

  主要回收两部分:废弃常量无用的类

  无用的类:该类所有的实例都已经被回收,java堆中不存在该类的任何实例。

加载该类的ClassLoader已经被回收。

该类对应的java.lang.Class对象没有在任何地方被引用,无法通过反射访问该类方法。

  废弃常量:例如“abc”常量没有任何对象引用它,也没有其他地方引用了这个字面量。

 

垃圾收集算法:

  标记-清除算法

标记阶段:首先从根对象开始进行遍历,对从根对象可以访问到的对象都打上一个标记。

 

清除阶段:对堆内存从头到尾进行线性的遍历,如果发现某个对象没有标记为可达对象,则就将其回收。

 

缺点:效率不高。标记清除后会产生大量不连续的内存碎片。空间碎片太多会导致以后分配较大对象时,需要提前触发一次垃圾回收。

  复制算法:用于回收新生代。将一块内存上还存活的对象复制到另一块上,再一次清除之前的那块。

通常用一块较大的Eden空间,和两块较小的Survivor空间。每次使用 Eden 和其中的一块 Survivor。

当回收时,将 Eden 和 Survivor 中还存活的对象一次性拷贝到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

Hotspot 虚拟机默认 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80% + 10%),只有10%的内存是会被“浪费”的。

缺点:存在空间浪费。

 

  标记-整理算法:用于回收老年代。标记后,让存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

缺点:GC暂停时间增长。

  分代收集算法:将java堆分为新生代和老年代,新生代只有少量存活,采用复制算法。

而老年代有大批存活,所以采用标记-整理算法或者标记-清除算法。

 

垃圾收集器:

  Serial收集器:用于新生代,采用复制算法,单线程收集器,它在垃圾收集时,必须暂停其他所有的工作线程。

是虚拟机运行在Client模式下的默认新生代收集器。优点:简单高效。

  ParNew收集器:用于新生代,采用复制算法,Serial的多线程版本,是在Server模式下的虚拟机中的首选新生代收集器。

除了Serial之外,目前有它能和CMS配合。这里的并行的多线程,是指多条垃圾收集线程并行工作,但用户线程仍然处于等待状态。

  Parallel Scavenge收集器:用于新生代,采用复制算法,也是并行的多线程收集器。也被称为吞吐量优先收集器。

它的目标是达到一个可控制的吞吐量,而其他收集器是尽可能缩短垃圾收集的停顿时间。

10秒收集一次,每次停顿100毫秒,现在变为5秒一次,每次停顿70毫秒。停顿时间是降下来了,但是吞吐量也下降了。

低停顿时间,适合需要与用户交互的程序,能提升用户的体验。

高吞吐量可以高效利用CPU时间,适合后台运算不需要太多交互的任务。

  Serial Old收集器:用于老年代,采用标记-整理算法单线程。主要用于Client模式下的虚拟机。

  Parallel Old收集器:用于老年代,采用标记-整理算法多线程

在注重吞吐量以及CPU资源敏感的场合,都可以考虑Parallel Scavenge + Parallel Old 。

  CMS收集器:用于老年代,采用标记-清除算法。是一种以获取最短回收停顿时间为目标的收集器。

适合重视服务响应速度,希望系统停顿时间最短,给用户带来较好的体验的场合。

 优点:并发收集,低停顿。这里的并发是指用户线程和垃圾收集线程同时执行(但不一定是并行的,可能是交替执行的)。

CMS分为4个步骤:初始标记,并发标记,重新标记,并发清除。初始标记和重新标记还是要暂停用户线程的,但是时间很短。

缺点:因为是基于标记-清除算法,所以会有大量空间碎片产生。

  G1(Garbage-First)收集器:面向服务器端应用。特点:

(1)并行与并发:能使用多个CPU来缩短停顿时间,让垃圾收集如用户线程并发执行。

(2)分代收集:虽然不用与其他收集器配合,但仍能分代收集。

(3)空间整合:整体上基于标记-整理算法,局部基于复制算法,都不会产生空间碎片,

(4)可预测的停顿:能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  G1将整个java堆分为多个大小相等的区域(Region),G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,

优先回收价值最大的Region(Garbage-First名字的由来)。这保证G1在有限的时间内可以获取尽可能高的收集效率。

G1的几个步骤:初始标记,并发标记,最终标记,筛选回收。

  G1收集器已在JDK 1.7 u4版本正式投入使用。

 

内存分配与回收策略:

  对象优先在Eden分配,当Eden没有足够空间时,则进行一次Minor GC。

  大对象直接进入老年代,大对象就是需要连续内存空间的对象。

  长期存活的对象将进入老年代。新生代对象每熬过一次Minor GC,年龄+1,加到一定程度(默认15),则晋升为老年代。

  动态对象年龄判定:如果在Survivor空间中有一半以上的对象是相同年龄的,那么>=这个年龄的对象直接晋升为老年代。

  Minor GC:发生在新生代的垃圾收集动作,很频繁,也很快。

  Major GC/Full GC:发生在老年代的GC,一般比Minor GC慢10倍以上。

 

  为什么要将堆内存分区:对于一个大型的系统,当创建的对象及方法变量比较多时,即堆内存中的对象比较多,

如果逐一分析对象是否该回收,效率很低。分区是为了进行模块化管理,管理不同的对象及变量,以提高 JVM 的执行效率。

 

 

 

 

六、类文件结构

   各种不同平台的虚拟机与所有平台都统一使用的程序存储格式----字节码是构成平台无关性的基石。 

  java语言中的各种变量,关键字和运算符号的语义最终都是由多条字节码命令组合而成的,

因此字节码命令所能提供的语义描述能力肯定会比java语言本身更加强大。

 

Class类文件的结构:

  任何一个Class文件都对应唯一一个类或接口的定义信息,但是类或接口也可以由类加载器直接生成。

  Class文件采用类似于C语言结构体的伪结构,只有两种数据类型,无符号数和表

  无符号数是基本的数据类型,可以用来描述数字,索引引用,数量值,字符串值。

  表示由多个无符号数或者其他表作为数据项构成的复合数据类型。

  整个Class文件的本质就是一张表。

 

  每个Class文件的头4个字节为魔数,用来确定这个文件是否为一个能被虚拟机接受的Class文件。值为0xCAFEBABE。

  不用扩展名来识别是因为扩展名可以随便改。

  第5,6字节是次版本号,7,8字节是主版本号。之后是常量池入口。

 

常量池:

  常量池主要存放字面量和符号引用。

  符号引用包括 类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。

  Java代码在进行javac编译的时候,没有“连接”这一步,而是虚拟机加载Class文件的时候进行动态连接。

  Class文件中不会保存各个方法,字段的最终内存布局信息。这些字段不经过运行期转换时无法得到内存入口的真正地址的,也就无法直接被虚拟机使用。

  常量池中每一项常量都是一个表。

  u2类型能表达的最大值为65535,所以java中如果有超过64kb英文字符的变量或方法名,将无法编译。

 

访问标志:

  常量池结束后,紧接着2个字节代表访问标志。这个标志用来识别一些类或者接口层次的访问信息。

  包括:是类还是接口,是否被public,abstract,final等修饰。

 

类索引,父类索引,接口索引集合:

  Class文件中由这三项数据来确定这个类的继承关系。

 

字段表集合:

  用于描述接口或类中声明的变量。

  字段包括类级变量,实例级变量,但不包括在方法内部声明的局部变量。

方法表集合:

  包括访问标志,名称索引,描述符索引,属性表集合。

属性表集合:

  字段表,方法表都携带自己的属性表集合,用于描述某些场景专有的信息。

  Code属性:java中方法体的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性内。接口和抽象类的方法不在Code属性里。

  Exceptions属性:用于列举出方法中可能抛出的受查异常。

  LineNumberTable属性:用于描述java源码行号与字节码行号之间的对应关系。

  LocalVariableTable属性:用于描述栈帧中局部变量表中的变量与java源码中定义的变量之间的关系。

  SourceFile属性:用于记录生成这个Class文件的源码文件名称。

  ConstantValue属性:通知虚拟机自动为静态变量赋值。

  InnerClasses属性:用于记录内部类和宿主类的关联。

  Synthetic属性:代表字段或方法不是由java源码直接产生的,而是由编译器自行添加的。

  StackMapTable属性:目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。

  Signature属性:用于记录泛型签名信息。

  BootstrapMethods属性:用于保存invokedynamic指令引用的引导方法限定符。

 

字节码指令:

  Java虚拟机的指令由一个字节长度的、代表某种特定操作含义的数字(操作码)、以及跟随其后的零至多个参数(操作数)构成。

  Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大部分指令只有一个操作码。

  因为只有一个字节长度,所以操作码总数不会超过256条。

 

  大多数指令都包含了其操作所对应的数据类型信息。

  大部分指令都没有支持byte,char,short,甚至没有任何指令支持boolean类型。(实际使用相应的int类型作为运算类型)

  编译器会在编译期或运行期将byte和short类型数据带符号扩展为相应的int类型数据,将boolean和char零位扩展为相应的int类型数据。

 

  加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。

  运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

  类型转换指令:可以将两种不同的数值类型进行相互转换。

  对象创建与访问指令:虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。

  操作数栈管理指令:虚拟机提供了一些直接操作操作数栈的指令。

  控制转移指令:虚拟机可以有条件或无条件的跳转,也就是修改PC寄存器的值。

  方法调用和返回指令:

  异常处理指令:虚拟机处理异常(catch语句)不是由自己吗指令实现的,而是采用异常表来完成。

  同步指令:虚拟机支持方法级的同步和方法内部一段指令序列的同步,这两种同步都是使用管程来支持的。

 

 

七、虚拟机类加载机制

 

 

    虚拟机的类加载机制:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。

  Java中,类的加载,连接,初始化都在运行期间完成。

  Java可以动态扩展的特性就是依赖运行期动态加载和动态连接这个特点来实现的。

  例如:可以到运行时才指定某一个接口的具体实现类。

 

类加载的时机:

  类的生命周期:加载,验证,准备,解析,初始化,使用,卸载,7个阶段。

  其中验证,准备,解析3部分统称为连接。

  连接指的是将Java类的二进制文件合并到jvm的运行状态之中的过程。

  解析有可能在初始化之后,是为了支持java的动态绑定。

 

类加载的过程:

  加载:通过一个类的全限定名来获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,

在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

  Class对象虽然是对象,但是存放在方法区里。

  数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。

  二进制字节流可以从多种不同的来源获取:

  (1)从zip包中读取,例如读取jar文件。

  (2)从网络中获取,典型的应用是Applet。

  (3)运行时动态生成,比如动态代理技术。

  (4)由其他文件生成,比如jsp应用。

 

 

  验证:为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  因为Class文件并不一定是从java源码编译而来的。

  如果验证过程出错的话,会抛出java.lang.VertifyError错误。

  验证阶段分为:文件格式验证,元数据验证,字节码验证,符号引用验证。

 

  准备:正式为类变量(被static修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

  这里的变量初始值是指默认的,也就是零值,而不是显示分配的。

  特例:如果是被static final 修饰,准备阶段直接显示初始化。

 

  解析:虚拟机将常量池内的符号引用替换为直接引用,有了直接引用,引用的目标必定在内存中存在。

  解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行。

 

  初始化:真正开始执行类中定义的java代码。

  以下几种情况会进行初始化:

  (1)使用new关键字实例化对象的时候。

  (2)读取或设置一个类的静态字段的时候。(static final 除外,因为在编译期就把结果放入常量池了)。

  (3)对类进行反射调用的时候。

  (4)初始化一个类的时候,如果父类还没初始化,则先初始化父类。

  (5)虚拟机启动时,虚拟机会先初始化主类,也就是含有main()方法的类。

 

  类的初始化过程:

  Student s = new Student();在内存中做了哪些事情?

  加载Student.class文件进内存

  在栈内存为s开辟空间

  在堆内存为学生对象开辟空间

  对学生对象的成员变量进行默认初始化

  对学生对象的成员变量进行显示初始化

  通过构造方法对学生对象的成员变量赋值

  学生对象初始化完毕,把对象地址赋值给s变量

 

类加载器:

  类加载阶段,要获取类的二进制字节流,实现这个动作的代码模块称为“类加载器”。

  对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性。

  即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,这两个类就不相等。

 

  启动类加载器(Bootstrap ClassLoader):使用C++实现(仅限于HotSpot),是虚拟机自身的一部分。负责将存放在\lib目录中的类库加载到虚拟机中。其无法被Java程序直接引用。

  扩展类加载器(Extention ClassLoader)由ExtClassLoader实现,负责加载\lib\ext目录中的所有类库,开发者可以直接使用。

  应用程序类加载器(Application ClassLoader):由APPClassLoader实现。负责加载用户类路径(ClassPath)上所指定的类库。

 

  双亲委派模型:要求除了顶层的启动类加载器外,其余加载器都应当有自己的父类加载器。

类加载器之间的父子关系,一般不会以继承的关系来实现,而是通过组合关系复用父加载器的代码。

  工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。

每个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,

只有到父加载器反馈自己无法完成这个加载请求(它的搜索范围没有找到所需的类)时,子加载器才会尝试自己去加载。

  为什么要使用:Java类随着它的类加载器一起具备了一种带优先级的层次关系。

比如java.lang.Object,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,

因此Object类在程序的各个类加载器环境中,都是同一个类。

  自己编写一个与rt.jar类库中已有类重名的java类,可以正常编译,但无法被加载运行。

 

 

委托机制的意义 — 防止内存中出现多份同样的字节码
比如两个类A和类B都要加载System类:

如果不用委托而是自己加载自己的,那么类A就会加载一份System字节码,然后类B又会加载一份System字节码,这样内存中就出现了两份System字节码。
如果使用委托机制,会递归的向父类查找,也就是首选用Bootstrap尝试加载,如果找不到再向下。这里的System就能在Bootstrap中找到然后加载,如果此时类B也要加载System,也从Bootstrap开始,此时Bootstrap发现已经加载过了System那么直接返回内存中的System即可而不需要重新加载,这样内存中就只有一份System的字节码了。

 

能不能自己写个类叫java.lang.System?

答案:通常不可以,但可以采取另类方法达到这个需求。
解释:为了不让我们写System类,类加载采用委托机制,这样可以保证父类加载器优先,父类加载器能找到的类,子加载器就没有机会加载。而System类是Bootstrap加载器加载的,就算自己重写,也总是使用Java系统提供的System,自己写的System类根本没有机会得到加载。

但是,我们可以自己定义一个类加载器来达到这个目的,为了避免双亲委托机制,这个类加载器也必须是特殊的。由于系统自带的三个类加载器都加载特定目录下的类,如果我们自己的类加载器放在一个特殊的目录,那么系统的加载器就无法加载,也就是最终还是由我们自己的加载器加载。

 

十二、Java内存模型与线程

 

Java内存模型:

  Java虚拟机规范中试图定义一种Java内存模型(JMM),用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的并发效果。

  C/C++直接使用物理硬件和操作系统的内存模型。

 

Java内存模型的主要目标:

  定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。

  此处的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,所以不存在竞争问题。

 

主内存和工作内存:

  所有的变量都存储在主内存,每条线程还有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝。

  线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存的变量。

  不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递需要通过主内存。

 

内存间交互操作:

  一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存,Java内存模型定义了8种操作:

 

 

volatile:

  关键字volatile是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义成volatile之后,具备两种特性:

  1.保证此变量对所有线程的可见性。当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的。而普通变量做不到这一点。

  2.禁止指令重排序优化。普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序。

 

  volatile变量在各个线程的工作内存,不存在一致性问题(各个线程的工作内存中volatile变量,每次使用前都要刷新到主内存)。

  但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的

 

  volatile变量读操作的性能消耗与普通变量几乎没有差别,但是写操作则可能慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

  大多数场景下,volatile的总开销仍然要比锁低,选在volatile的唯一依据是volatile的语义能否满足使用场景的需求

 

原子性,可见性,有序性:

  Java内存模型是围绕着在并发过程中如何处理原子性,可见性,有序性这3个特性来建立的。

  

  原子性:对基本数据类型的访问和读写是具备原子性的。对于更大范围的原子性保证,可以使用字节码指令monitorenter和monitorexit来隐式使用lock和unlock操作。

  这两个字节码指令反映到Java代码中就是同步块——synchronized关键字。因此synchronized块之间的操作也具有原子性。

  可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取之前从主内存刷新变量值来实现可见性的。

  volatile的特殊规则保证了新值能够立即同步到主内存,每次使用前立即从主内存刷新。synchronized和final也能实现可见性。

  final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么其他线程中就能看见final字段的值。

  有序性:Java程序的有序性可以总结为一句话,如果在本线程内观察,所有的操作都是有序的(线程内表现为串行的语义);

  如果在一个线程中观察另一个线程,所有的操作都是无序的(指令重排序和工作内存与主内存同步延迟线性)。

 

线程:

  线程是比进程更轻量级的调度执行单位。线程可以把一个进程的资源分配执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O),又可以独立调度(线程是CPU调度的最基本单位)。

 

实现线程的方式:使用内核线程实现,使用用户线程实现,使用用户线程+轻量级进程实现。

 

Java线程的实现:操作系统支持怎样的线程模型,在很大程度上就决定了Java虚拟机的线程是怎样映射的。

 

Java线程调度:

  线程调度是系统为线程分配处理器使用权的过程。

  协同式线程调度:实现简单,没有线程同步的问题。但是线程执行时间不可控,容易系统崩溃。

  抢占式线程调度:每个线程由系统来分配执行时间,不会有线程导致整个进程阻塞的问题。(java就是使用这种)

 

 状态转换:

   Java语言定义了6种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态:

  新建:创建后未启动;

  运行:对于 Java 来说,线程已经运行,但对于操作系统来说,可能在运行或等待;

  无限期等待:线程等待被其他线程唤醒,如调用了 wait、join 且没有指定超时时间;

  限期等待:线程等待一段时间后被系统唤醒,如调用了 sleep、wait、join 并设置了超时时间;

  阻塞:线程进入同步区域需要与其他线程协调同步,如需要进入 synchronized 区域但其他线程尚未退出此区域;

  结束:run 方法执行完成后,线程结束。

 

 

 

十三、线程安全与锁优化

  线程安全:当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调度方进行任何其他的协调操作,

调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

 

Java语言里的线程安全

  按照线程安全的“安全程度”由强至弱来排序,java里面各种操作共享的数据分为以下5类:不可变,绝对线程安全,相对线程安全,线程兼容,线程对立 。
  不可变:可以是基本类型的final;可以是final对象,但对象的行为不会对其状态产生任何影响,比如String的subString就是new一个String对象,各种Number类型如BigInteger和BigDecimal等大数据类型都是不可变的,但是同为Number子类型的AtomicInteger和AtomicLong则并非不可变。

  绝对线程安全:他是完全满足Brian Goetz给出的线程安全的定义,一个类要达到这种程度,需要付出很大的,甚至不切实际的代价。

  相对线程安全:这就是我们通常意义上的线程安全。需要保证对象单独的操作时线程安全的。比如Vector,HashTable,synchronizedCollection包装集合等。

  线程兼容:对象本身不是线程安全的,但可以通过同步手段实现。一般我们说的不是线程安全的,绝大多数是指这个。比如ArrayList,HashMap等。

  线程对立:不管调用端是否采用了同步的措施,都无法在并发中使用的代码。调用suspend()的时候,目标线程会停下来,但却仍然持有在这之前获得的锁定。此时,其他任何线程都不能访问锁定的资源,除非被"挂起"的线程恢复运行。对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。

 

线程安全的实现方法

  互斥同步(阻塞同步)

  在多线程访问的时候,保证同一时间只有一条线程使用。而互斥是实现同步的一种手段,临界区(Critical Section),互斥量(Mutex),信号量(Semaphore)都是主要的互斥实现方式。java里最基本的互斥同步手段是synchronized,还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,ReentrantLock比synchronized增加了一些高级功能:等待可中断、可实现公平锁以及锁可以绑定多个条件

  非阻塞同步

  互斥和同步最主要的问题就是阻塞和唤醒所带来的性能问题,所以这通常叫阻塞同步(悲观的并发策略)。随着硬件指令集的发展,我们有另外的选择:基于冲突检测的乐观并发策略,通俗讲就是先操作,如果没有其他线程争用共享的数据,操作就成功,如果有,则进行其他的补偿(最常见就是不断的重试),这种乐观的并发策略许多实现都不需要把线程挂起,这种同步操作被称为非阻塞同步。

  无同步方案:

  有一些代码天生就是线程安全的,不需要同步。其中有如下两类:

  可重入代码(Reentrant Code):纯代码,具有不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等特征,它的返回结果是可以预测的。
  线程本地存储(Thread Local Storage):把共享数据的可见范围限制在同一个线程之内,这样就无须同步也能保证线程之间不出现数据争用问题。可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。

  

锁优化:

  自旋锁与自适应自旋:线程挂起和恢复的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力,在许多应用中,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,可以让后请求锁的线程等待一会儿,但不放弃处理器的执行时间,让线程执行一个忙循环(自旋)。
        自旋锁默认的自旋次数值是10次,可以使用参数-XX:PreBlockSpin更改。
        自适应自旋意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
        锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。
        锁粗化:如果虚拟机探测到有一系列连续操作都对同一个对象反复加锁和解锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
        轻量级锁:使用对象头的Mark Word中锁标志位代替操作系统互斥量实现的锁。轻量级锁并不是用来代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
        轻量级锁是在无竞争的情况下使用CAS(Compare-and-Swap)操作去消除同步使用的互斥量。
        偏向锁:和轻量级锁原理基本一致,但偏向锁在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
        

 

 

posted @ 2017-10-22 21:13  __Meng  阅读(484)  评论(0编辑  收藏  举报