「总结篇」别再说自己不会JVM了,看完这篇能和面试官扯上半小时(下)

·  阅读 1120

前言

如果本篇文章有错,欢迎各路大神疯狂diss~~当然喽,如果你看了这篇文章有所收获,那就疯狂点赞吧,你的点赞就是对我的最大鼓励。可以顺便加个关注哦,回家不迷路,不定期更新博客~~

前面我写过一篇关于JVM的总结性的文章,收到的效果还是蛮大的,👍240+直接打破历史新高(本来也就几个赞),可别提多高兴了。连接放在这里了:「总结篇」别再说自己不会JVM了,看完这篇能和面试官扯上半小时(上),小伙伴们可以先去看看。

评论区不少的朋友的留言也让我学到了不少东西,也有不少的小伙伴满足我的心愿,评论区疯狂diss,嘿嘿嘿。好了,少说废话,这次也是一篇总结性的文章(JVM总结性的文章计划就写这两篇了)。先把这篇文章的大致结构放上来,希望对大家有所帮助。

浅析Clsss文件

Class类文件结构

在写 Class 文件结构的时候,我整个人都炸裂了,但必须得清楚,想要对JVM有一点了解,Class 文件就是一道必须迈过去的坎。未来可期,大家加油啊!

0、Class 的今生前世

在了解Class文件的内部组成结构之前,我想有必要知道一下什么是Class文件。我们大概都知道的吧,Java具有很好的跨平台特性,Class文件可以说是功不可没。Java源程序在编译器的作用下,生成了字节码文件,JVM只需要执行字节码文件就可以了。有了字节码文件,这样就解除了Java虚拟机和Java语言之间的耦合。不同的语言在各自编译器的作用下可以被编译成Java虚拟机执行的字节码文件,在也是Java虚拟机能够支持多种语言的原因。看完下面这张图就明白了。

纵观Class文件,是采用类似于C语言的结构体的伪结构来存储数据。这种伪结构只有两种数据类型:**“无符号数”和“表”,**下面来分别解释一下。

  • **无符号数:**属于基本数据类型,以u1、u2、u4、u8来代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成的字符串值。
  • **表:**由多个无符号数或者其他表作为数据项构成的复合数据类型。为了便于区分,所有的表命名都习惯性地以“_info”结尾。表用于描述具有层次关系的复合结构数据,整个Class文件本质上也可以视为一张表。

无符号数和表的关系可以用下面一张图来表示。

还有一个问题,Class文件中存在无符号数和表,它们在Class文件中是怎么排列的呢?

这些结构按照预先规定好的顺序进行排列,相邻之间没有任何间隙。当Java虚拟机加载某个Class文件时,JVM就是根据上图中的结构1去解析Class文件,加载Class文件到内存中,并在内存中分配相应的空间。具体的Class文件结构如下图:

1、魔数与 Class 文件版本

每个Class文件的前面4个字节被称为魔数(Magic Number),它的唯一作用就是确定这个文件是否能Java虚拟机加载 。关于魔数,不仅是Class文件,还有很多文件格式标准都使用魔数进行身份标识,比如GIF或者JPEG等。下面我们就写一个代码片段。

 import java.io.Serializable;
  
 public class Test implements Serializable, Cloneable{
       private int num = 1;
  
       public int add(int i) {
           int j = 10;
           num = num + i;
           return num;
      }
 }
复制代码

使用 16 进制编辑器打开 Class 文件,显示的内容如下:

在上面的图中**,ca fe ba be** 是魔数,这没什么好说的,魔数唯一的作用就是标识该Class文件能够被Java虚拟机执行,如果文件开头不是0XCAFEBABE,就不能被JVM被别。紧接着魔数后面的4个字节是版本号,上图中显示的是00 00 00 34,前面两个字节是次版本号,后面两个字节是主版本号。

2、常量池(重点)

主、次版本之后的是常年池入口,常量池可以 比喻为 Class 文件里的资料仓库,它是 Class 文件结构中与其他项目关联最多的数据项。常量池主要存放两大常量:字面量符号引用,字面量是比较接近于Java语言层面的的常量概念,如文本字符串、被声明为final的常量值,而符号引用的话主要包括下面几类:

  • 被模块导出或开放的包(Package)
  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 字段句柄和方法类型
  • 动态调用点和动态类型

另外,Class 文件不会保存各个方法、字段在内存中的布局信息,也就是说,Class 不经过JVM运行时加载的话,是无法得到真正的内存入口。常量池中的每一个数据项都是一个表,具体内容如下:

以上14种表都有自己的结构,这里就不再一一介绍,主要以CONSTANT_Class_infoCONSTANT_Utf8_info这两张为例。下面我们就来先看看CONSTANT_Class_info这张表:

类型名称数量
u1tag1
u2name_index1
  • tag: 标志位,占用一个字节大小,区分常量类型。
  • name_index: 常量池的索引值,可以将它理解为一个指针,指向常量池中索引为 CONSTANT_Utf8_info 的常量表,此常量代表了类或者接口的的全限定名。

接下来我们看看CONSTANT_Utf8_info

类型名称数量
u1tag1
u2name_index1
u1byteslength

这里没什么好说的,length的长度说明了这个 UTF_-8 编码的字符串长度是多少字节,另外Java字符串中的最大字符长度为65534,有兴趣的朋友可以看看这篇文章:我说我精通字符串,面试官竟然问我Java中的String有没有长度限制!?

看到这里,不难理解也有相互之间的引用关系,下面用一张图来理解 CONSTANT_Utf8_infoCONSTANT_Class_info 的关系:

3、访问标志

结束常量池的分析之后,后面的内容就相对简单一些了。

紧跟在常量池后面的是访问标志,对于访问标志,主要是标志一些类或接口的层次信息。包括:这个 Class 文件是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final类型。具体的标志位以及标志含义见下图:

4、类索引、父类索引与接口索引

访问标志之后的两个字节就是类索引,类索引之后的两个字节就是父类索引,父类索引后的两个字节就是接口索引,Class 文件中由这三项确定该类型的继承关系。

下面一张图说明了类索引查找全限定名的过程:

5、字段表集合

字段表集合紧跟在接口索引集合后面,主要是用来描述接口或者类中声明的变量。值得注意的是,这里的变量指的是类级别的变量以及实例级的变量,但不包括在方法内部声明的局部变量。

字段表集合的结构如下:

类型名称数量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attributes_account1
attribute_infoattributesattribute_count

字段修饰符放在access_flags项目中,它的访问标志如下:

紧跟在access_flags标志之后的是两项索引值:name_indexdescriptor_index。它们是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符。

6、方法表集合

方法表紧跟在在字段表集合之后, Class 文件存储格式中对方法的描述访和对字段的描述几乎采用了完全一致的方式。方法表结构如下:

类型名称数量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attributes_account1
attribute_infoattributesattribute_count

7、属性表集合

前面说方法和字段表集合的时候,在他们的结构体中,我们可以清楚看到有一个叫做attribute_info的表,这就是属性表。属性表的结构如下:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u1infoattribute_length

说起属性表集合,这里就不得不提一下Code属性。Java程序方法体里面的代码经过Javac编译器处理后,最终变成了字节码指令存储在Code属性内。但不是所有的方法表都存在Code属性,比如接口或者抽象类中的方法就不存在Code属性。如果存在Code属性表,那么它的结构如下:

类型名称数量
u2attribute_name_index1
u4attribute_length1
u2max_stack1
u2max_locals1
u4code_length1
u1codecode_length
u2exception_table_length1
exception_infoexception_tableexception_table_length
u2attribute_count1
attribute_infoattributesattribute_count

我觉得有必要对Code属性的部分结构进行解释一下,那我们就开始吧。

  • attribute_name_index:指向CONSTANT_Utf8_info型的常量,此常量值固定为“Code”,它代表了该属性的属性名称。
  • attribute_length:属性值的长度。
  • max_stack:操作数栈的最大深度。
  • max_locals:局部变量表所需的存储空间。它的基本单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。

总之,Code 属性可以说是 Class 文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法里面的Java代码)和元数据(Metadata,包括类、字段、方法定义以及其他信息)两部分,那么在整个 Class 文件里,Code 属性用于描述代码,其他的所有数据项都用于描述元数据。

字节码指令

Java虚拟机的指令是由一个字节长度的、代表着某种特定操作含义的数字以及跟随其后的多个代表此操作数的参数构成。主要有以下几类:

  1. 字节码与数据类型
  2. 加载和存储指令
  3. 运算指令
  4. 类型转换指令
  5. 对象创建与访问指令
  6. 操作数栈管理指令
  7. 控制转移指令
  8. 方法调用和返回指令
  9. 异常处理指令

关于字节码指令,后面结合实例就会明白一些,感兴趣的朋友可以先看看这篇文章。

Java虚拟机字节码指令表

类加载机制

概述

首先得明白什么是类加载了?Java虚拟机把描述类的数据从 Class 文件加载到内存,并进行数据校验、转换解析和初始化的过程,被称为类加载机制。与C、C++不同的是,Java的类型加载、连接和初始化都是在程序运行期间完成的。既然如此,那我们就先来看看类加载的时机。

类加载的时机

从一个类型被加载到内存中,到被垃圾收集器进行回收,它的生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载 这七个阶段。发生顺序如图:

值得一提的是,加载、验证、准备、初始化和卸载 这五个步骤顺序是确定的,类加载的过程必须按照这种顺序开始,而解析阶段则不一定。为了支持Java语言的运行时绑定特性,解析阶段也可以在初始化阶段之后再开始。通常情况下,这些阶段相互交叉混合完成,会在一个阶段执行的过程中调用另一个过程。

那么,类是怎么时候被加载到内存中去的了?就正常而言,Class 文件被类加载器(ClassLoader)主动加载到内存中有以下两种情况:

  • 调用类的构造器方法
  • 调用类的静态变量或者静态方法

类加载的过程

1、加载

加载阶段是整个类加载过程的一个阶段,在类加载阶段,Java虚拟机需要完成以下三件事:

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

2、验证

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流信息符合《Java虚拟机规范》的全部要求,保证这些代码运行后不会危害Java虚拟机自身的安全。验证阶段是非常重要的,从代码量和耗费的执行性能角度讲,验证阶段的工作量在虚拟机的类加载过程占了相当大的比重。验证阶段主要由以下4个部分组成:

  • 文件格式验证: 验证字节流是否符合 Class 文件格式的规范,并且被当前的虚拟机版本处理。例如验证文件是否以魔数0xCAFEBABE开头;主、次版本号是否在当前的Java虚拟机接受范围之内等等。
  • 元数据验证: 对字节码描述的信息进行语意分析,以保证其描述的信息是否符合《Java语言规范》的要求。比如验证这个类是否有父类;如果这个类不是抽象类,那么它是否实现了父类或者接口中要求实现的所有方法。
  • 字节码验证: 通过数据流分析和控制流分析,确定语意是否合法、符合逻辑。
  • 符号引用验证: 最后一个阶段的校验行为发生在虚拟机把符号引用转为直接引用的时候,这个过程在解析阶段发生。符号引用验证可以看作是对类自身以外(常量池子中的各种符号引用)的各类信息进行匹配校验。

3、准备

准备阶段是为类中定义的变量(即静态变量、被static修饰的变量)分配内存并设置类变量初始值的阶段,注意这时进行内存分配的仅包括类变量,而不包括实例变量。

4、解析

Java虚拟机将常量池内的符号引用转化为直接引用的过程。那么是符号引用,什么是直接引用?

  • **符号引用:**用符号表示所引用的目标,符号可以是任何形式的字面量。
  • **直接引用:**直接引用是可以直接指向目标的指针、相对偏移量或者能间接定位到目标的句柄,直接引用和虚拟机实现的内存布局直接相关。

解析可以分为以下几类:

  • 类或接口的解析
  • 字段解析
  • 方法解析
  • 接口方法解析

5、初始化

初始化阶段,Java虚拟机才开始真正执行类中编写的Java代码,将主导权交给应用程序。那么是时候出发初始化这个过程了?通常情况下,有且只有以下六种情况必须对类进行初始化。

  • 遇到new 、getstatic、putstatic、invokestatic这四条字节码指令时,如果没有进行初始化,则需要先出发初始化。通常能生成这四条指令的Java代码场景有:
    • 使用new关键字
    • 读取或设置一个类型的静态字段的时候
    • 调用一个类型的静态方法的时候
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果该类没有初始化,则必须先进行初始化
  • 当初始化类的时候,它的父类没有初始化,则需要先触发其父类的初始化
  • 虚拟机启动的时候,必须先初始化包含main()方法的类
  • 定义default方法的接口需要在实现类之前被初始化。
  • 最后一条是Java对动态语言的支持

在初始化阶段,会通过程序编写的代码去初始化类变量和其他资源。从本质上来讲,初始化阶段就是执行类构造器()方法的过程。那这个方法是怎么产生的了?

  • 由编译器自动收集类中所有类变量的赋值动作和静态语句块合并的,收集的顺序是由语句在原文件中出现的顺序决定的。静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量,前面的静态语句块可以赋值,但是不能访问。
  • ()方法与类的构造函数不同,它不需要显式调用父类的构造器。
  • 父类的()方法先执行,父类的静态语句块要优于子类的变量赋值操作。
  • ()方法对于类或者接口来说并不是必须的,如果一个类中没有静态语句块,也就没有对变量的赋值操作。
  • 接口中不能使用静态语句块,但仍然有变量初始化的操作,因此也有()的生成。

类加载器

前面我们说了类加载的过程,那么是通过什么实现类加载这个过程的?没错,Java设计团队是通过一个叫做类加载器的东西来实现的。类加载器的作用就是:通过一个类的全限定名来获取描述该类的二进制字节流。 下面,我们就来详细介绍一下吧。

类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名空间。换句话说,比较两个类是否相等,只有当它们的类加载器相同的时候,这两个类才相等。即便这两个类是来源于同一个 Class 文件也不例外。

双亲委派模型

Java有三层类加载器:

  • 启动类加载器(Bootstrap Class Loader)

  • 扩展类加载器 (Extension Class Loader)

  • 应用程序类加载器 (Application Class Loader)

此外,用户还可以自定义类加载器进行必要的扩展。不过在JDK9之前的Java程序就是这三者配合完成。接下来就看看类加载器的双亲委派模型。

双亲委派模型的工作是这样的:如果一个类加载器收到了一个类加载的请求,那么它会把这个请求交给父类加载器去执行,每一个层次的类加载器都是这样。通常只有父类无法完成这个类加载请求后,子类才会自己去完成类加载。那么,为什么会有这样的一种工作机制呢?

这使得Java中的类随它的类加载器一起,具备了一种带有优先级的层次关系。据一个例子,无论哪一个类加载器加载java.lang.Object这个类,都委托给处于模型最顶端的启动类加载器。因此,就能保证Object类在程序的各个类加载器的环境中是同一个类。

字节码执行引擎

运行时栈帧结构

前面我们说过静态变量存储在方法区,那么像方法体内的局部变量存储在哪里呢?程序在运行时,Java虚拟机会为每一个方法分配一个栈帧,这个栈帧存储在Java虚拟机栈中。没有印象的话,建议看一下这篇文的上半部分。每一个栈帧可以由以下四个部分组成:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 返回地址

概念结构图如下:

1、局部变量表

局部变量表是一组变量值的存储空间,用来存放方法参数和方法内部定义的局部变量。前面说过,局部变量表的最小单位是变量槽。一个变量槽可以存放一个32位以内的数据类型,像boolean、byte、short、char、int、float都可以用一个变量槽储存,long、double得用两个变量槽储存。

有一点需要注意,如果执行的是实例方法(没有被static修饰方法),那么局部变量表的第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。关于局部变量,如过没有赋初始值,就不能使用。

2、操作数栈

操作数栈也被称为操作栈,它是一个后入先出的栈。在方法刚开始执行的时候,操作栈是空的,在方法执行的过程中会有各种字节码指令往操作数栈中写入和提取内容。Java虚拟机被称为“基于栈的执行引擎”,这里的栈指的就是操作数栈。

3、动态链接

每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个音哥引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。指向方法的符号引用在运行时转化为直接引用的部分,我们称之为动态连接。

4、返回地址

返回地址也就是方法在结束的时候,返回到自身调用的地方。返回的调用可以分为“正常调用完成”和遇到异常时的“异常调用完成”。

方法调用

方法调用不等同于方法中的代码被执行,方法调用阶段唯一的作用就是确定哪一个被执行,暂时还没涉及到方法内部的具体运行过程。所有的方法调用在 Class 文件里储存的都只是符号引用,而不是方法在实际运行时内存布局的入口地址(也就是之前说的直接引用)。

1、解析

解析过程是一个静态过程,在编译期就能完全确定,在类加载的解析阶段就会把涉及的符号引用全部转为明确的直接引用,而不必要等到运行期再去完成。

2、分派

分派相对来说比解析复杂得多,涉及到多态特性在Java虚拟机的中的具体实现,分派可以分为静态分派和动态分派。

  • 静态分派

    静态分派和“重载”的实现相关,变量的类型在编译期就能确定,下面举一个简单的例子。

    public class StaticDispatch{
    
    	static abstract class Human{}
      static class Man extends Human{}
      static class Woman extends Human{}
      
      public void sayHello(Human human){
        System.out.println("hello,guy!");
      }
      public void sayHello(Man man){
        System.out.println("hello,man!");
      }
      public void sayHello(Woman woman){
        System.out.println("hello,woman!");
      }
      
      public static void main(String[] args){
        Human man = new Man();     //载编译期确定的类型:由Human决定
        Human woman = new Woman();  //new 这个操作是在运行时决定的
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);  //hello,guy!
        sr.sayHello(woman);  //hello,guy!
      }
    }
    复制代码

    上面说的就是重载,在编译阶段就决定了使用哪一个重载的版本**。参数的类型是要看等号前面的部分,后面部分new的操作是在运行时进行的。**

  • 动态分派

    动态分派就和“重写”有很大的关系了,我们再用前面的代码来举例:

    public class StaticDispatch{
    
    	static abstract class Human{
        protected abstract void sayHello();
      }
      static class Man extends Human{
        protected void sayHello(){
          System.out.println("man say hello!");
        }
      }
      static class Woman extends Human{
         protected void sayHello(){
          System.out.println("woman say hello!");
        }
      }
      
      public static void main(String[] args){
        Human man = new Man();    
        Human woman = new Woman();  
        man.sayHello();    //man say hello!
        woman.sayHello();   //woman say hello!
        man = new Woman();
        man.sayHello();    //woman say hello!
      }
    }
    复制代码

    可以看到,实际的输出类型是由new操作来决定的。另外,重写只与方法有关,字段不参与。若子类继承父类,子类的字段名和父类相同,则子类的字段会覆盖父类的字段。

基于栈的字节码解释引擎

前面我们说过Java虚拟机的字节解释引擎是基于操作数栈的,想必你也听说过Android虚拟机的字节码解释引擎是基于寄存器的,那么这两者之间的差异在哪里呢?

它们的指令集本身就不同,下面举一个计算 1 + 1 的例子。

基于栈的指令集是这个样子的:

iconst_1
iconst_1
iadd
istore_0
复制代码

基于寄存器的字节码的指令集是这样的:

mov eax,1
mov eax,1
复制代码

既然这两套指令集会同时并存,那么肯定有各自的优势。基于栈的指令集最大的优点就是有很好的可移植性,因为寄存器由硬件直接提供,程序直接依赖寄存器,则不可避免的受到硬件的约束。栈寄存器的主要缺点就是执行速度会比较慢。

Java内存模型

概述

这里需要注意的一点,在面试的时候可能会问到Java内存模型,这得和JVM内存结构区分开来。Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。

Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。那这里,我们就不得不说一下硬件的效率与一致性问题了。

硬件的效率与一致性

我们知道,计算机CPU的处理速度与内存访问的速度差了好几个数量级,为了解决这个问题,就有了缓存。但是这也带来了新的问题:缓存一致性。

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,此外每条线程还有自己的工作内存,工作内存中保存了该线程使用的变量的主内存副本。不同线程对变量的操作都必须在工作内存中完成,而不能读写主内存中的数据,不同线程也不能访问对方的工作内存,线程间变量值的传递都需要通过主内存来完成。

内存间的交互

现在问题来了,如果多个线程都要对主内存的相同数据进行操作,怎样实现把数据从主内存中拷贝到工作内存,再把工作内存的数据同步到主内存。为此,JMM定义了以下8种原子操作:

  • lock:作用于主内存的变量,把一个变量标记为线程独占状态。
  • unlock:作用于主内存的变量,把处于锁定状态的变量释放,这样才可以被其他的线程获取。
  • read:作用于主内存的变量,把主内存中的变量拷贝到线程的工作内存中。
  • load:作用于工作内存的变量,把从主内存获得的变量放入到工作内存的副本中。
  • use:作用于工作内存的变量,把工作内存的变量传递给执行引擎。
  • assign:作用于工作内存的变量,把从执行引擎获得的值赋给工作内存的变量。
  • store:作用于工作内存的变量,把工作内存的变量传递给主内存。
  • write:作用于主内存的变量,把从store得到的值放入主内存的变量中。

Volatile

关于volatile的文章,网上一抓一大把,有兴趣的小伙伴可以自行去查阅。这里的话,讲的应该会比较含蓄

其实,关于volatile只要抓住了它的两大特性就行:

  • 对所有的线程具有可见性

    简单来说,每一个线程都有自己的工作内存,当一个线程修改主变量的值,其他的线程也会知道。

  • 禁止指令重排序优化

    Java虚拟机可能会对输入的代码进行乱序执行,但是计算的结果和按序执行的结果是一样的,但是使用volatile修饰的变量是禁止指令重排序执行的。

Java与线程

说到Java线程,就不得不说操作系统里的进程概念。那么进程与线程的区别又是什么呢?用一句话来说:进程是资源分配的基本单位,线程是程序执行的基本单位。

线程实现

线程的是实现主要有以下三种:

  • 内核线程实现

    所谓的内核线程实现,就是直接由操作系统内核支持的线程。内核通过调度器对线程进行调度,并负责把线程的任务映射到各个处理器上。

  • 用户线程实现

    用户线程的建立、同步、销毁完全是在用户态完成的,操作速度快、消耗的资源少。

  • 混合实现

    顾名思义,混合实现是内核线程实现和用户线程实现综合。在混合实现下,既存在用户线程,也存在轻量级进程。

线程调度

线程调度有两种方式:

  • 协同式
  • 抢占式

Java线程是以抢占式进行调度的,这里简单说一下协同式和抢占式的区别。

协同式的实现比较简单,线程的执行时间由线程本身控制,只有当线程自己的任务执行完毕之后才会进行线程切换。但是缺点也十分明显,那就是线程的执行时间不可控制。抢占式是由系统分配执行的时间片,时间片一旦用完,就会主动让出执行时间。

这里需要说明的一点是,我们可以为线程设置优先级,这样线程就会获得更多的执行时间。Java线程可以设置不同的优先级,总共有10个等级。

状态转换

其实理解了进程的状态转换的话,线程就不难理解了,所以肝完这篇文章之后就开始整操作系统了。下面,我就简单介绍以下线程的状态转换。请看下面这张图:

  • 新建(New):创建一个线程,这个时候线程处于可以运行的状态,但是没有获得CPU的时间片。
  • 运行(Running):线程获得了CPU的时间片,处于运行的状态。
  • 无限期等待(Waiting):不能获得CPU的时间片,必须被其他的线程唤醒才能处于活跃的状态。
  • 限期等待(Timed Waiting):线程处于等待状态,不过这个状态是有一定时间的。无需被其他的线程唤醒,时间结束,线程自动转换到可运行状态。
  • 阻塞(Blocked):注意与“等待状态”区别开来,处于阻塞状态的线程正在等待一个锁,一旦获得了锁,线程就处于可运行状态。
  • 结束(Terminated):线程终止。

线程安全与锁优化

线程安全

什么是线程安全?它的定义是这样的:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行其他协调操作,调用这个对象的行为都可以获得正确的结果,那么称这个对象就是线程安全的。

有了线程安全的定义,那么Java是如何实现线程安全的?

  • 互斥同步

    是一种最常见也是最主要的并发正确性保障的手段。同步是指在在多个线程并发访问共享数据时,共享数据在同一个时刻只能被一个线程使用。互斥是实现同步的一种方法,临界区、互斥量、信号都是常见的互斥实现的方法。在Java中,可以使用synchronizedReentrantLock对对象进行加锁。

  • 非阻塞同步

    互斥同步属于一种阻塞同步,从解决问题上看,互斥同步属于一种悲观的并发策略,而非阻塞同步是一种基于冲突检测的乐观并发策略。

锁优化

1、自旋锁与自适应锁

我们前面说到过,线程挂起和线程恢复都需要在内核态中完成,这对并发带来了很大的压力。那自旋锁是什么呢?当线程访问被加锁的共享数据的时候,该线程不会处于阻塞的状态(不放弃CPU的执行权),看看持有锁的线程是否会很快放弃锁。为了让线程不放弃CPU的执行权,只需让线程执行一个忙循环(自旋),这项技术就被称之为自选锁。

当然自旋锁也是由缺点的,它虽然不进行线程切换,但是仍然占用CPU的执行权。如果等待对象锁被占用很长的时间,同样也会带来性能上的浪费。为此,在自旋锁的基础上进行了优化。比如,设置了默认自旋的次数,把自旋锁上升级成为自适应自旋锁。

2、锁消除

锁消除的主要判断依据来源于逃逸分析的技术支持,如果堆上所有的数据都不会逃逸出去被其他的线程访问到,那么就可把它当作栈上的数据进行对待,认为它们是私有的,就不需要进行同步加锁。

3、锁粗化

通常情况下,我们都会把加锁的范围尽量缩小,只有在共享数据的实际作用域才进行加锁。但是锁粗化的场景于此相反,有时我们对一个对象反复进行加锁和解锁会导致不必要的性能浪费。

4轻量级锁

要理解轻量级锁以及后面说的偏向锁,就得了解一下Java虚拟机的对象头。对象头有两部分组成:

  • 第一部分:存储对象自身的运行时数据,如哈希码、GC分代年龄等,官方称之为“Mark word”。
  • 第二部分:存储指向方法区的对象类型的指针。

了解了对象的内存布局,那么开始介绍轻量级锁。如果说偏向锁是只允许一个线程获得锁,那么轻量级锁就是允许多个线程获得锁,但是只允许他们顺序拿锁,不允许出现竞争,也就是拿锁失败的情况。轻量级锁的加锁过程时这样的:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:

  2. 拷贝对象头中的Mark Word复制到锁记录中;

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。

  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。

  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

5、偏向锁

偏向锁的来源是因为Hotsopt的作者研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由统一线程多次获得,而线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁获得锁的过程分为以下几步:

  1. 初始时对象的Mark Word位为1,表示对象处于可偏向的状态,并且ThreadId为0,这是该对象是biasable&unbiased状态,可以加上偏向锁进入第2步。如果一个线程试图锁住biasable&biased并且ThreadID不等于自己ID的时候,由于锁竞争应该直接进入第4步撤销偏向锁。
  2. 线程尝试用CAS将自己的ThreadID放置到Mark Word中相应的位置,如果CAS操作成功进入到第3步,否则进入第4步。
  3. 进入到这一步代表当前没有锁竞争,Object继续保持biasable状态,但此时ThreadID已经不为0了,对象处于biasable&biased状态。
  4. 当线程执行CAS失败,表示另一个线程当前正在竞争该对象上的锁。当到达全局安全点时(cpu没有正在执行的字节)获得偏向锁的线程将被挂起,撤销偏向(偏向位置0),如果这个线程已经死了,则把对象恢复到未锁定状态(标志位改为01),如果线程还活着,则把偏向锁置0,变成轻量级锁(标志位改为00),释放被阻塞的线程,进入到轻量级锁的执行路径中,同时被撤销偏向锁的线程继续往下执行。
  5. 同步代码块。

参考:《深入理解Java虚拟机》第三版 周志明著

分类:
后端
标签: