多线程(二)Java内存模型、同步关键字

文章目录

本系列文章:
  多线程(一)线程与进程、Thread
  多线程(二)Java内存模型、同步关键字
  多线程(三)线程池
  多线程(四)显式锁、队列同步器
  多线程(五)可重入锁、读写锁
  多线程(六)线程间通信机制
  多线程(七)原子操作、阻塞队列
  多线程(八)并发容器
  多线程(九)并发工具类
  多线程(十)多线程编程示例

一、Java内存模型

1.1 初识JMM

  在并发编程中主要需要解决两个问题:

  1. 线程之间如何通信(指线程之间以何种机制来交换信息);通信是指线程之间以何种机制来交换信息,主要有两种:共享内存和消息传递
  2. 线程之间如何完成同步

  Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。

1.1.1 JMM抽象模型

  Java线程之间的通信由Java内存模型(简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:

线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

  共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。

  图中的线程A和线程B之间要完成通信的话,要经历如下两步:

  1. 线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
  2. 线程B从主内存中读取最新的共享变量。

  可以看出:JMM通过控制主内存与每个线程的本地内存之间的交互,来保证提供内存可见性

1.1.2 内存间交互操作

  JMM定义了8个操作来完成主内存和工作内存的交互操作。:

  • 1、lock(锁定)
      作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
  • 2、unlock(解锁)
      作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • 3、read(读取)
      作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load使用;
  • 4、write(操作)
      作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
  • 5、load(载入)
      作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本;
  • 6、use(使用)
      作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  • 7、assign(赋值)
      作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  • 8、store(存储)
      作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用。

  如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。图示:

1.2 重排序

  编写的程序都要经过优化后(编译器和处理器对程序进行优化,以提高程序运行效率)才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守as-if-serial规则和happens-before规则

1.2.1 为什么会重排序*

  在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,重排序需要满足以下两个条件:

  1. 在单线程环境下不能改变程序运行的结果;
  2. 存在数据依赖关系的不允许重排序。

  重排序不会影响单线程环境的执行结果,但是可能会破坏多线程的执行语义

1.2.2 重排序过程

  在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。源代码所经历的重排序流程:

  1属于编译器重排序,而2和3统称为处理器重排序。这些重排序会导致线程安全的问题,一个很经典的例子就是DCL(双重检验锁)问题。
  针对编译器重排序,Java内存模型(JMM)的编译器重排序规则会禁止一些特定类型的编译器重排序。
  针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。

1.2.3 as-if-serial规则

  as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器和处理器都必须遵守as-if-serial语义。
  为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。这也就意味着,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
  例子:

	double pi  = 3.14;    //A
	double r   = 1.0;     //B
	double area = pi * r * r; //C

  A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

1.3 内存屏障

  内存屏障是对一类仅针对内存读、写操作指令的跨处理器架构(比如ARM)的称呼,其作用是禁止编译器、处理器重排序从而保障有序性

1.3.1 硬件级别的内存屏障

  在不同的硬件上,有不同的内存屏障,比如在X86上,有以下内存屏障指令:

sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。

  当然也可以通过原子指令的方式来实现内存屏障的功能。如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。软件锁通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。

1.3.2 JVM级别的内存屏障

  为了性能优化,JMM在不改变(单线程)正确语义的前提下,允许编译器和处理器对指令序列进行重排序。如果想阻止重排序,可以添加内存屏障。
  JMM内存屏障(JVM级别的内存屏障)分为四类:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2确保Load1的数据的装载先于Load2及所有后续装载指令的装载
StoreStoreBarriersStore1;StoreStore;Store2确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据的装载先于Store2及所有后续存储指令的存储
StoreLoad BarriersStore1;StoreLoad;Load2确保Store1的数据对其他处理器可见(刷新到内存)先于Load2及所有后续的装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

  StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

1.4 volatile的禁止重排序特性

1.4.1 volatile的特性

  Java编译器会在生成指令序列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
  简而言之,volatile变量自身具有以下两个特性:

  • 1、可见性
      对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 2、原子性
      对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

  volatile 适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。再具体说,必须同时满足下面两个条件才能保证在并发环境的线程安全:

  1)对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值(boolean flag = true)。
  2)该变量没有包含在具有其他变量的不变式中,也就是说,不同的 volatile 变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile。

1.4.2 volatile内存语义的实现

  为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

  举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
  可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

  "NO"表示禁止重排序。为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM采取的策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障

  需要注意的是:volatile写操作是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。这4种内存屏障的作用:

  1. StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
  2. StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
  3. LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
  4. LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

  volatile写操作内存屏障图示;

  volatile读操作内存屏障图示;

  上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

1.5 happens-before

  JMM把happens-before要求禁止的重排序分为了下面两类:会改变程序执行结果的重排序、不会改变程序执行结果的重排序。

对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)

1.5.1 happens-before规则

  JMM可以通过happens-before关系向开发者提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法

  第一条是JMM对开发者的承诺。从开发者的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。
  第二条是JMM对编译器和处理器重排序的约束原则。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

  JMM为开发者在上层提供了8条规则,这样开发者就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。
  具体的happens-before规则:

  • 1、程序顺序规则
      一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 2、监视器锁规则
      对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • 3、volatile变量规则
      对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读。
  • 4、传递性
      如果A happens-before B,且B happens-before C,那么A happens-before C。
  • 5、start()规则
      如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • 6、join()规则
      如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 7、程序中断规则
      对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  • 8、对象finalize规则
      一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

  还看之前的例子:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

  利用程序顺序规则(规则1)存在三个happens-before关系:1. A happens-before B;2. B happens-before C;3. A happens-before C。这里的第三个关系是利用传递性进行推论的。A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序

1.5.2 as-if-serial与happens-before的区别
  1. as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  2. as-if-serial语义给编写单线程程序的开发者创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的开发者创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  3. as-if-serial语义和happens-before共同的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
1.5.3 happens-before与JMM的关系


  一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java开发者来说,happens-before规则简单易懂,便于开发。

1.6 final的禁止重排序特性

  对于final域,编译器和处理器要遵守两个重排序规则:
  1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

1.6.1 final域重排序规则

  先看final域为基本类型的情况,示例:

public class FinalDemo {
    private int a;  //普通域
    private final int b; //final域
    private static FinalDemo finalDemo;

    public FinalDemo() {
        a = 1; // 1. 写普通域
        b = 2; // 2. 写final域
    }

    public static void writer() {
        finalDemo = new FinalDemo();
    }

    public static void reader() {
        FinalDemo demo = finalDemo; // 3.读对象引用
        int a = demo.a;    //4.读普通域
        int b = demo.b;    //5.读final域
    }
}

  假设线程A在执行writer()方法,线程B执行reader()方法。

  • 1、写final域重排序规则
      写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
  1. JMM禁止编译器把final域的写重排序到构造函数之外;
  2. 编译器会在final域写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。

  writer方法,做了两件事情:

  1. 构造了一个FinalDemo对象;
  2. 把这个对象赋值给成员变量finalDemo。

  下面是一种可能的程序执行时序图:

  由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。
  写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo。

  • 2、读final域重排序规则
      读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。
      read()方法主要包含了三个操作:
  1. 初次读引用变量finalDemo;
  2. 初次读引用变量finalDemo的普通域a;
  3. 初次读引用变量finalDemo的final与b;

  假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:

  读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。
  读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用

  • 3、final域为引用类型
      针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。示例:
	public class FinalReferenceDemo {
	    final int[] arrays;
	    private FinalReferenceDemo finalReferenceDemo;
	
	    public FinalReferenceDemo() {
	        arrays = new int[1];  //1
	        arrays[0] = 1;        //2
	    }
	
	    public void writerOne() {
	        finalReferenceDemo = new FinalReferenceDemo(); //3
	    }
	
	    public void writerTwo() {
	        arrays[0] = 2;  //4
	    }
	
	    public void reader() {
	        if (finalReferenceDemo != null) {  //5
	            int temp = finalReferenceDemo.arrays[0];  //6
	        }
	    }
	}

  针对上面的代码,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论:

  由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。

  • 4、对final修饰的对象的成员域读操作
      JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。
  • 5、final重排序的总结
      基本数据类型:
  1. final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
  2. final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。

  引用数据类型:

  额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量重排序。

1.6.2 为什么final引用不能从构造函数中“逸出”

  上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“逸出”。
  以下面的例子来说:

public class FinalReferenceEscapeDemo {
    private final int a;
    private FinalReferenceEscapeDemo referenceDemo;

    public FinalReferenceEscapeDemo() {
        a = 1;  //1 写final域
        referenceDemo = this; //2 this引用在此“逸出”
    }

    public void writer() {
        new FinalReferenceEscapeDemo();
    }

    public void reader() {
        if (referenceDemo != null) {  //3
            int temp = referenceDemo.a; //4
        }
    }
}


  假设一个线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作2使得对象还未完成构造前就为线程B可见。即使这里的操作2是构造函数的最后一步,且在程序中操作2排在操作1后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的操作1和操作2之间可能被重排序。实际的执行时序可能如下:

  可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

1.7 volatile关键字是如何保证可见性、有序性

  volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。
  观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3 个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面
    的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

二、同步机制

  线程安全问题的产生前提是多个线程并发访问共享数据。因此,有种保障线程安全的方法:将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其访问,就是利用这种思路保证线程安全的线程同步机制。
  在这种机制中,一个线程在访问共享数据前必须申请相应的锁,一个锁一次只能被一个线程持有。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放相应的锁。
  锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区。因此,共享数据值允许在临界区内进行访问,临界区一次只能被一个线程执行。图示:

  锁具有排他性,即一个锁一次只能被一个线程持有,这种锁就叫作排他锁互斥锁。这种锁的实现方式代表了锁的基本原理,读写锁是排他锁的改进实现方式。
  按照锁的实现方式划分,锁包括隐式锁和显式锁。隐式锁是通过synchronized关键字实现的;显式锁是通过Lock的实现类实现的

2.1 锁的作用*

  锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。

  • 1、保障原子性
      锁是通过互斥保障原子性的,互斥指一个锁只能被一个线程持有,这保证了临界区代码一次只能被一个线程执行,使得临界区代码锁执行的操作自然具有不可分割的特性,即具备了原子性。
      从互斥的角度看,锁其实是将多个线程对共享数据的访问由本来的并发改成串行
  • 2、保障可见性
      可见性的保障是通过写线程重刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。其实,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在执行临界区代码之前(获得锁之后)可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被“推送”到该线程执行处理器的高速缓存中,从而对读线程同步。因此,锁能够保证可见性。
  • 3、保障有序性
      锁能够保障有序性。写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即读线程对这些写线程操作的感知顺序和源代码顺序一致,这是锁对原子性和可见性保障的结果。

  锁保障可见性、原子性和有序性的前提:

  1. 这些线程在访问同一组共享数据的时候,必须使用同一个锁。
  2. 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据,而没有对其进行更新的话,也需要在读取时持有相应的锁。

2.2 可重入性

  可重入性一个线程在其持有一个锁的时候,能否再次(或者多次)申请该锁。如果一个线程持有一个锁的时候还能继续成功申请该锁,那么就称该锁是可重入的,否则就是非可重入的。

  可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值是0,表示相应的锁还没有被任何线程持有。每次线程获得一个可重入锁的时候,该锁的计数器值会+1.每次一个线程释放锁的时候,该锁的计数器属性值会-1。

  Java中锁的调度策略也包括公平策略和非公平策略,相应的锁被称为公平锁和非公平锁。
  隐式锁(synchronized)属于非公平锁,显式锁(Lock)既支持公平锁也支持非公平锁
  一个锁所保护的共享数据的数量大小称为该锁的粒度。一个锁保护的共享数据量大,就称该锁的粒度粗,否则就称该锁的粒度细。
  锁的开销包括锁的申请和释放锁产生的开销,以及锁可能导致的上下文切换的开销,这些开销主要是处理器时间。同时,锁的不正确使用也会导致一些线程活性故障:

  1. 锁泄漏。锁泄漏是指一个线程某个锁后,由于程序的错误、缺陷导致该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。
  2. 锁的不正确使用还可能导致死锁、锁死线程等活性故障。

2.3 锁的五种优化方式*

  锁的使用建议:高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC方法。

  • 1、减少锁持有时间
	public synchronized void syncMethod(){  
	        othercode1();  
	        mutextMethod();  
	        othercode2(); 
	    }

  上面的代码,在进入方法前就要得到锁,其他线程就要在外面等待。因此,可以改成只需要在有线程安全要求的程序代码上加锁。示例:

	public void syncMethod(){  
	        othercode1();  
	        synchronized(this){
	            mutextMethod();  
	        }
	        othercode2(); 
	    }
  • 2、减小锁粒度
      将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。典型的减小锁粒度的案例就是采用分段锁的ConcurrentHashMap(相比于锁住整个对象的HashTable)。
  • 3、锁分离
      最常见的锁分离就是读写锁,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥。即保证了线程安全,又提高了性能。
      读写分离思想可以延伸,只要操作互不影响,锁就可以分离。
      如LinkedBlockingQueue,元素的出队和入队用了两把锁:
  • 4、锁粗化
      通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。
      但是,凡事都有一个度,适当地锁粗化,反而有利于性能的优化 。
      示例:
	public void demoMethod(){  
	        synchronized(lock){   
	            //do sth.  
	        }  
	        //...做其他不需要的同步的工作,但能很快执行完毕  
	        synchronized(lock){   
	            //do sth.  
	        } 
	    }

  此时就可以将两把锁合成一把锁:

	public void demoMethod(){  
	    //整合成一次锁请求 
	    synchronized(lock){   
	         //do sth.   
	         //...做其他不需要的同步的工作,但能很快执行完毕  
	    }
	}
  • 5、锁消除
      锁消除是在编译器级别的事情。
      在即时编译器中,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。
      比如Vector和StringBuffer这样的类,它们中的很多方法都是有锁的。在一些不会有线程安全问题的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能。
      示例:
	public static void main(String args[]) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 2000000; i++) {
            createStringBuffer("JVM", "Diagnosis");
        }
        long bufferCost = System.currentTimeMillis() - start;
        System.out.println("craeteStringBuffer: " + bufferCost + " ms");
    }

    public static String createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }

  例子中的StringBuffer.append是一个同步操作,但是StringBuffer却是一个局部变量,并且方法也并没有把StringBuffer返回,所以不可能会有多线程去访问它。
  此时就可以通过设置JVM参数的形式开启锁消除功能:

	-XX:+DoEscapeAnalysis -XX:+EliminateLocks

三、synchronized

3.1 synchronized的作用*

  synchronized关键字是用来控制线程同步的。在多线程的环境下,synchronized控制的代码段不被多个线程同时执行,以达到保证并发安全的效果
  synchronized关键字实现的锁称为内部锁,内部锁是一种排他锁,能够保证原子性、可见性和有序性。之所以被称为内部锁,是因为线程对内部锁的申请与释放的动作都是由Java虚拟机负责实现的,开发者看不到这个锁的获取和释放过程。

  在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间。
  在JDK1.6之后,JVM层面对synchronized进行了较大优化(自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等),大大减少了锁操作的开销,提升了synchronized的效率。

  synchronized的缺点:

  • 1、效率低
      锁的释放情况少,试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程。
  • 2、不够灵活(读写锁更灵活)
      加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的;
  • 3、无法知道是否成功获取到锁

3.2 synchronized的使用

  synchronized可使用在代码块和方法中。

3.2.1 同步方法*

  synchronized关键字修饰的方法称为同步方法,synchronized修饰的静态方法被称为同步静态方法,synchronized修饰实例方法被称为同步实例方法。
  修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
  修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁。synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类上锁。
  代码示例:

	//实例同步方法,锁住的是类的实例对象
	public synchronized void method(){
		//...
	}

	//静态同步方法,锁住的是类对象。
	public static synchronized void method(){
		//...
	}
3.2.2 同步代码块*

  当synchronized关键字用到代码块上时,被锁的对象可以是类的实例对象、类对象或任意的对象。
  锁住该类实例对象的代码示例:

	//同步代码块,锁住的是该类的实例对象
	synchronized(this){
		//...
	}

  如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。

	//同步代码块,锁住的是该类的类对象
	synchronized(CurrentClass.class){
		//...
	}

  作为锁的变量通常用final修饰,因为锁变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁。
  尽量不要使用synchronized(String a) 。因为在Java内存模型中的字符串常量池具有缓存功能,这意味着不相关的线程可能会去竞争同一个锁。

	//同步代码块,锁住的是任意对象
	final Object lock = new Object();
	synchronized(lock){
		//...
	}
3.2.3 同步方法和同步块的选择

  在进行两者的选择之前,先看下两者的区别:

  • 1、同步方法默认用this或者当前类class对象作为锁
      同步方法使用关键字synchronized修饰方法,而同步代码块主要是修饰需要进行同步的代码,用synchronized(object){代码内容}进行修饰;
  • 2、同步代码块比同步方法粒度更细
      可以选择只同步会发生同步问题的部分代码而不是整个方法。

  同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
  记住一条原则:同步的范围越小越好

3.3 synchronized的底层实现原理

3.3.1 monitorenter/monitorexit(同步块)*

  在使用synchronized的过程中并没有看到显式的加锁和解锁过程。要想了解这个过程,可以使用javap命令,查看字节码文件,示例:

public class SynchronizedDemo {

    public void method() {
    	synchronized(this) {
    		System.out.println("synchronized 代码块");
    	}
    }
}

  用javap -v SynchronizedDemo.class命令可以查看该class文件的内容,主要关注一下method方法相关的字节码,图示:

  可以看出在执行同步代码块之前之后都有一个monitor字样,这意味着:一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令
  为什么会有两个monitorexit呢?主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。

3.3.2 ACC_SYNCHRONIZED(同步方法)*

  示例:

public class SynchronizedDemo2 {
	public synchronized void method() {
		System.out.println("synchronized 方法");
	}
}

  字节码文件:

  synchronized修饰的方法并没有monitorenter指令和monitorexit指令,而是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

3.3.3 synchronized可重入原理

  synchronized可重入的原理:重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。
  synchronized可重入在代码中的具体意义是:在一个synchronized方法中可以调用同一个锁的另一个synchronized方法,示例:

	synchronized void m1() {
		System.out.println("m1 start");
		m2();
		System.out.println("m1 end");
	}
	
	synchronized void m2() {
		System.out.println("m2");
	}
3.3.4 当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B*

  分几种情况(前3种情况都是两个方法是实例同步方法或静态同步方法):

  1. 不能。其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的synchronized修饰符要求执行方法时要获得对象的锁,如果已经进入A方法说明对象锁已经被取走,那么试图进入B方法的线程就只能在等锁池(不是等待池)中等待对象的锁。
  2. 如果这个方法内部调用了wait(释放了锁),则可以进入其他synchronized方法。
  3. 如果其他方法都加了synchronized关键字,并且内部没有调用wait,则不能。
  4. synchronized static修饰的方法和只有synchronized 修饰的方法不影响,因为两者锁住的对象不同

  第一种情况示例:

	public class SynchronizedDemo {
		
	    public static void main(String[] args) {
	    	SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
	    	
	    	Thread thread1 = new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						synchronizedDemo.method1();
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			});
	    	Thread thread2 = new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						synchronizedDemo.method2();
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			});
	    	thread1.start();
	    	thread2.start();    	
	    }
	    
	    public synchronized void method1() throws InterruptedException {
	    	System.out.println("synchronized method1 start "+new Date());
	    	Thread.sleep(10000);
	    	System.out.println("synchronized method1 end "+new Date());
		}
	    
	    public synchronized void method2() throws InterruptedException {
	    	System.out.println("synchronized method2 start "+new Date());
	    	Thread.sleep(10000);
	    	System.out.println("synchronized method2 end "+new Date());
		}    
	}

  结果:

synchronized method1 start Sun Oct 17 21:46:59 CST 2021
synchronized method1 end Sun Oct 17 21:47:09 CST 2021
synchronized method2 start Sun Oct 17 21:47:09 CST 2021
synchronized method2 end Sun Oct 17 21:47:19 CST 2021

  从结果可以看出,当thread1在执行method1方法时,thread2不能执行method2方法。

3.4 CAS和自旋锁

  此处介绍CAS和自旋锁,是为了接下来介绍synchronized锁升级做铺垫。

3.4.1 CAS是什么*

  CAS(compare and swap),即比较交换。
  CAS是一种基于乐观锁的操作。在Java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
  CAS要解决的问题就是保证原子操作
  CAS操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成 B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。示例:

  JUC包下的类大多是使用CAS操作来实现的,如:AtomicInteger、AtomicBoolean、AtomicLong等。
  CAS一定要volatile变量配合,这样才能保证每次拿到的变量是主内存中最新的那个值,否则旧的预期值A对某条线程来说,永远是一个不会变的值A,只要某次CAS操作失败,永远都不可能成功。
  以AtomicInteger为例,AtomicInteger的getAndIncrement()方法底层就是CAS实现,关键代码是compareAndSwapInt(obj, offset, expect, update) 。其含义就是,如果obj内的value和expect相等,就证明没有其他线程改变过这个变量,那么就更新它为 update ,如果不相等,那就会继续重试直到成功更新值。

3.4.2 CAS怎么保证原子性

  原子性是指一个或多个操作在CPU执行过程中不被中断的特性,要么执行,要么不执行,不能执行到一半,即原子性操作是不可被中断的一个或一系列操作。
  为了保证CAS的原子性,CPU提供了下面两种方式:总线锁定、缓存锁定。

  • 1、总线锁定
      总线(BUS)是计算机组件间数据传输方式,也就是说通过总线,CPU与其他组件连接传输数据,就是靠总线完成的,比如CPU对内存的读写。
      总线锁定是指CPU使用了总线锁,所谓总线锁就是使用CPU提供的LOCK#信号,当CPU在总线上输出LOCK#信号时,其他CPU的总线请求将被阻塞。
  • 2、缓存锁定
      总线锁定方式虽然保持了原子性,但是在锁定期间,总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。所以现代CPU为了提升性能,通过锁定范围缩小的思想设计出缓存行锁定(缓存行是CPU高速缓存存储的最小单位)。
      所谓缓存锁定是指CPU对缓存行进行锁定,当缓存行中的共享变量回写到内存时,其他CPU会通过总线嗅探机制感知到该共享变量是否发生变化,如果发生变化,让自己对应的共享变量缓存行失效,重新从内存读取最新的数据,缓存锁定是基于缓存一致性机制来实现的,因为缓存一致性机制会阻止两个以上CPU同时修改同一个共享变量(现代CPU基本都支持和使用缓存锁定机制)。
      缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的CPU抛弃缓存的数据或者从内存重新读取。
  • 3、缓存一致性
      当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU高速缓存中,那么CPU进行计算时就可以从它的高速缓存读取数据和向其中写入数据,当运算结束后,再将高速缓存中的数据刷新到主存中。
      在多核CPU中,每个线程可能运行在不同的CPU中,因此每个线程运行时有自己的高速缓存。假如初始时 i 的值为0,那么我们希望两个线程执行完加1操作之后 i 的值变为2。但事实会是这样吗?可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作后,i的值为1,然后线程2把i的值写入内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
      也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。为了解决缓存不一致问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK锁的方式;
  2)通过缓存一致性协议;

  在早期的CPU中,是通过在总线上加LOCK锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK锁的话,也就是说阻塞了其他CPU通过总线对其它部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。但这种方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
  所以就出现了缓存一致性协议。该协议保证了每个缓存中使用的共享变量的副本是一致的。它的核心思想是:当CPU向内存写入数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存是无效的,那么它就会从内存重新读取。

3.4.3 CAS的适用场景*
  • CAS适合简单对象的操作,比如布尔值、整型值等;
  • CAS适合线程冲突较少的情况,如果太多线程在同时自旋,会导致CPU开销很大。
3.4.4 自旋锁是什么*

  自旋锁:是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
  CAS是实现自旋锁的基础,CAS利用CPU指令保证了操作的原子性,以达到锁的效果。
  自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
  线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
  自旋锁的相关属性:

JDK1.6 中-XX:+UseSpinning 开启;
-XX:PreBlockSpin=10 为自旋次数;
JDK1.7 后,去掉此参数,由 jvm 控制。

3.4.5 自旋锁的问题和优点

  自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

  • 自旋锁存在的问题
      1)如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。因此,自旋锁使用不当会造成CPU使用率极高
      2)如果自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁会存在“线程饥饿”问题
  • 自旋锁的优点
      自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是活跃的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。

  自旋锁适合于锁使用者保持锁时间比较短并且锁竞争不激烈的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。

3.4.6 CAS会产生什么问题*
  • 1、ABA 问题
      比如说一个线程threadOne从内存地址V中取出地址中的值A,这时候另一个线程threadTwo也从该内存中取出值A,并且threadTwo进行了一些操作将地址V中的值变成了B,然后threadTwo又将地址V的数据变成A【即由于threadTwo的操作,导致地址中的值经历了一个A -> B -> A 的变化过程】。
      这时候线程threadOne进行CAS操作发现内存中仍然是A,然后threadOne操作成功。尽管线程threadOne的CAS操作成功,但可能存在潜藏的问题。
      从JDK1.5开始,JDK的atomic包里提供了一个类 AtomicStampedReference,该类是解决ABA问题的方法之一。
      AtomicStampedReference它内部不仅维护了对象值,还维护了一个时间戳(时间戳,实际上它可以使任何一个整数,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。
  • 2、循环时间长开销大
      对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
  • 3、只能保证一个共享变量的原子操作
      当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以通过这两个方式解决这个问题:

使用互斥锁来保证原子性;
将多个变量封装成对象,通过AtomicReference来保证原子性。

3.5 synchronized锁升级

  Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
  要了解synchronized锁升级的原理,还得了解Java对象头

3.5.1 Java对象头

  在同步的时候是获取对象的monitor,即获取到对象的锁。对象的锁信息就存储在Java对象的对象头中。
  如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。
  Java对象头的长度:

  Java对象头里的Mark Word里默认的存放的对象的hashcode、对象分代年龄和锁标志位等信息。32位虚拟机中Mark Word默认存储结构为:

锁状态25bit4bit1bit 是否是偏向锁2bit 锁标识位
无锁状态对象的hashcode对象分代年龄001

  JDK1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态-->偏向锁状态-->轻量级锁状态-->重量级锁,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
  锁升级过程中,对象的MarkWord变化为:

  在64位虚拟机下,Mark Word是64bit大小的:

3.5.2 无锁

  没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。

3.5.3 偏向锁

  大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。因此,为了让线程获得锁的代价更低,引入了偏向锁。
  偏向锁:指的就是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放

  • 1、偏向锁的获取
      当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
  • 2、偏向锁的撤销和升级
      当线程1访问代码块并获取锁对象时,会在Java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程要竞争锁对象,而偏向锁不会主动释放,因此还是存储的线程1的threadID)。
      此时,需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
  • 3、偏向锁的获得和撤销流程
  • 4、偏向锁的取消
      偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
      如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
3.5.4 轻量级锁
  • 1、偏向锁升级为轻量级锁
      轻量级锁是指当锁是偏向锁的时候,被第二个线程B访问,此时偏向锁就会升级为轻量级锁,线程B会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。
  • 2、轻量级锁的升级
      线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间,然后使用CAS把对象头中的内容替换为线程1存储的锁记录的地址;
      如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
      但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
3.5.5 重量级锁*

  一个线程获取重量级锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
  重量级锁通过对象内部的监视器(monitor)实现,而其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
  synchronized锁升级原理:

  1. 在锁对象的对象头里面有一个threadid字段,线程在第一次访问的时候threadid为空,jvm让其持有偏向锁,并将threadid设置为其线程id【此时为偏向锁】;
  2. 线程尝试再次获取锁的时候,会先判断threadid是否与其线程id一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁【此时为轻量级锁】;
  3. 线程通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁【此时为重量级锁】。

  当JVM检测到线程间不同的竞争状态时,就会根据需要自动切换到合适的锁,这种切换就是锁的升级。升级是不可逆的,也就是说只能从低到高,也就是偏向-->轻量级-->重量级,不能降级
  锁的升级的目的是:减低锁带来的性能消耗。锁升级是在JDK1.6版本上实现的。
  不同锁的对比:

偏向锁轻量级锁重量级锁
适用场景只有一个线程进入同步块多个线程进入同步块,但是线程进入同步块时间错开而未争抢锁多个线程进入同步块并争用锁
本质取消同步操作CAS操作代替互斥同步互斥同步
优点不阻塞,执行效率高(只有第一次获取偏向锁时需要CAS操作,后面只是对比threadid)不会阻塞不会空耗CPU
缺点适用场景局限,若竞争产生,会有额外的偏向锁撤销的消耗长时间获取不到锁,会空耗CPU阻塞,上下文切换,消耗系统资源

  synchronized的执行过程:

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁。
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1。
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁。
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

3.6 ReentrantLock和synchronized的区别*

  1. ReentrantLock显式获得、释放锁,synchronized隐式获得释放锁。
  2. ReentrantLock可响应中断,synchronized是不可以响应中断的。
  3. ReentrantLock是API级别的,synchronized是JVM级别的。
  4. ReentrantLock可以实现公平锁,synchronized只能实现非公平锁。
  5. ReentrantLock通过Condition可以绑定多个条件,synchronized只能锁定一个对象。
  6. 底层实现不一样, synchronized是同步阻塞,使用的是悲观并发策略;lock是同步非阻塞,采用的是乐观并发策略。
  7. Lock是一个接口,synchronized是Java中的关键字,synchronized是内置的语言实现。
  8. synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在 finally 块中释放锁。
  9. 通过Lock可以知道有没有成功获取锁,synchronized却无法办到。

3.7 Synchronized相关参数优化

  Synchronized是基于底层操作系统的Mutex Lock实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。
  Synchronized在修饰同步代码块时,是由monitorenter和monitorexit指令来实现同步的。进入monitorenter指令后,线程将持有Monitor对象,退出monitorenter指令后,线程将释放该Monitor对象。
  当Synchronized修饰同步方法时,并没有发现monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。
  当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED访问标志。如果设置了该标志,执行线程将先持有Monitor对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该Mointor对象,当方法执行完成后,再释放该 Monitor 对
象。
  在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生stop the word 后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加JVM参数关闭偏向锁来调优系统性能,示例:

	-XX:-UseBiasedLocking // 关闭偏向锁(默认打开)
	-XX:+UseHeavyMonitors // 设置重量级锁

  在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。
  在高负载、高并发的场景下,我们可以通过设置JVM参数来关闭自旋锁,优化系统性能,示例:

	-XX:-UseSpinning // 参数关闭自旋锁优化 (默认打开)
	-XX:PreBlockSpin // 参数修改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 控制

  还可以通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。
  减少锁竞争,是优化Synchronized同步锁的关键。我们应该尽量使Synchronized同步锁处于轻量级锁或偏向锁,这样才能提高Synchronized同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高Synchronized同步锁在自旋时获取锁资源的成功率,避免Synchronized同步锁升级为重量级锁。

四、volatile

  volatile可用于修饰共享可变变量,即没有用final修饰的实例变量或静态变量。
  volatile关键字常被称为轻量级锁,其作用于锁的作用有相同的地方:保证可见性和有序性。不同的是,在原子性方面,它仅能保证写volatile变量操作的原子性,但没有锁的排他性;其次volatile关键字的使用不会引起上下文切换(这也正是volatile被称为轻量级的原因)。因此,volatile更像是一个轻量级简易(功能比锁有限)锁。
  volatile解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。

  如果是count++操作,使用如下类实现:AtomicInteger count = new AtomicInteger(); count.addAndGet(1); 如果是JDK1.8,推荐使用LongAdder对象,比 AtomicLong 性能更好(减少乐观锁的重试次数)。

4.1 volatile的作用

  volatile是轻量级的同步机制,volatile的作用包括:保障可见性、保障有序性和保证long/double型变量写操作的原子性

4.1.1 保证内存可见性*

  内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态
  volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。示例代码:

	public class T01_HelloVolatile {
		/*volatile*/ boolean running = true; 
		void m() {
			System.out.println("m start");
			while(running) {
			}
			System.out.println("m end!");
		}
		
		public static void main(String[] args) {
			T01_HelloVolatile t = new T01_HelloVolatile();
			new Thread(t::m, "t1").start();
	
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			t.running = false;
		}
	}

  结果:

m start

  当用volatile修饰boolean变量时,结果:

m start
m end!

  volatile关键字在可见性方面仅仅是保证读线程能够读取到共享变量的相对新值。
  对于引用型变量和数组变量,volatile关键字并不能保证读线程能够读取到相应对象的字段(实例变量、静态变量)、元素的相对新值。

4.1.2 禁止指令重排序*

  上文已有介绍。

4.1.3 保证long/double型变量写操作的原子性*

  用volatile修饰long/double型变量,可以保证其写操作的原子性。

4.2 volatile的应用场景

  volatile除了用于保障long/double型变量的读、写操作的原子性,其典型使用场景还包括以下几个:

  • 1、使用volatile变量作为状态标志
  • 2、使用volatile保障可见性
      该场景中,多个线程共享一个可变状态变量,其中一个线程更新了该变量之后,其他线程在无须加锁的情况下也能看到该变量的更新。
  • 3、使用volatile变量代替锁
      volatile关键字并非锁的替代品,volatile关键字和锁各自有其适用条件。前者更适合于多个线程共享一个状态变量(对象),而后者更适合于多个线程共享一组状态变量。
  • 4、使用volatile实现简易版读写锁
      volatile 常用于多线程环境下的单次操作(单次读或者单次写)。
      在该场景中,读写锁是通过混合使用锁和volatile变量来实现的,其中锁用于保障共享变量写操作的原子性,volatile变量用于保障共享变量的可见性。该场景的一个典型例子是计数器。示例代码:
	public class Counter{
		private volatile long count;
		public long value(){
			return count;
		}
		public void increment(){
			synchronized(this){
				count++;
			}
		}
	}
  • 5、和CAS结合使用
      可以参考JUC包下的类,比如 AtomicInteger。
  • 6、单例模式
      单例模式的双重校验锁也用到了volatile。
      在双重校验锁中,如果不加volatile,会可能发生:对象未初始化完全的时候,就赋值给变量了。加了volatile后,可以保证对象初始化完全后,再赋值给变量。示例:
	public class Singleton {  
	    private volatile static Singleton singleton;  
	    private Singleton (){}  
	    public static Singleton getSingleton() {  
	        if (singleton == null) {  
	            synchronized (Singleton.class) {  
	                if (singleton == null) {  
	                    singleton = new Singleton();  
	                }  
	            }  
	        }  
	        return singleton;  
	    }  
	}

4.3 volatile的实现原理

  volatile是如何来保证可见性的呢?看下在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么事情。
  Java代码:

	//instance是volatile变量
	instance = new Singleton(); 

  转变成汇编代码:

	0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

  Lock前缀的指令在多核处理器下会引发了两件事情:

1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

  因此,volatile的两条实现原则:

  • 1、Lock前缀指令会引起处理器缓存回写到内存
      Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存 。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。
  • 2、一个处理器的缓存回写到内存会导致其他处理器的缓存无效

4.4 volatile的使用优化

  JDK1.7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。
  LinkedTransferQueue源码:

	/** 队列中的头部节点 */
	private transient final PaddedAtomicReference<QNode> head;
	/** 队列中的尾部节点 */
	private transient final PaddedAtomicReference<QNode> tail;
	static final class PaddedAtomicReference <T> extends AtomicReference T> {
		//使用很多4个字节的引用追加到64个字节
		Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
		PaddedAtomicReference(T r) {
			super(r);
		}
	}
	public class AtomicReference <V> implements java.io.Serializable {
		private volatile V value;
		//...
	}

  LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就是将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个字节。

  • 为什么追加64字节能够提高并发编程的效率?
      因为对于英特尔酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器的L1、L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着,如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。
  • 是不是在使用volatile变量时都应该追加到64字节?
      当然不是的。在两种场景下不应该使用这种方式:
  1. 缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
  2. 共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。

  这种追加字节的方式在JDK1.7下可能不生效,因为JDK1.7会淘汰或重新排列无用字段,需要使用其他追加字节的方式。

4.5 volatile的相关问题

4.5.1 Java中能创建volatile数组吗

  能。Java中可以创建volatile类型数组,不过volatile修饰的只是一个指向数组的引用,而不是整个数组。
  如果改变引用指向的数组,将会受到volatile的保护,但是如果多个线程同时改变数组的元素,volatile标识符就不能起到之前的保护作用了。

4.5.2 volatile能使得一个非原子操作变成原子操作吗

  能。一个典型的例子是在类中有一个long类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,你最好是将其设置为volatile。
  因为Java中读取long类型变量不是原子的,需要分成两步,如果一个线程正在修改该long变量的值,另一个线程可能只能看到该值的一半(前 32 位)。但是对一个volatile型的long或double变量的读写是原子。

4.5.3 volatile和synchronized的区别*
  1. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  3. volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改可见性和原子性。
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

五、乐观锁和悲观锁

  • 悲观锁
      总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁( 共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。每次访问资源都会加锁,执行完同步代码释放锁,synchronized和ReentrantLock属于悲观锁。
      AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock。
  • 乐观锁
      总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。 乐观锁适用于多读的应用类型,这样可以提高吞吐量。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS。
  • 两种锁的适用场景
      乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以 一般多写的场景下用悲观锁就比较合适。

5.1 乐观锁的两种实现方式

  • 1、版本号机制
      一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
  • 2、CAS算法
      即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。
      CAS算法涉及到三个操作数:需要读写的内存值V、进行比较的值A、拟写入的新值B。当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个 自旋操作,即不断的重试。

5.2 乐观锁的缺点

  和CAS的缺点一样:ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作。

5.3 CAS与synchronized的使用情景

  简单来说,CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)。

  1. 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗CPU资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  2. 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

六、synchronized和volatile

6.1 synchronized和volatile的区别*

  synchronized表示只有一个线程可以获取作用对象的锁,执行临界区代码,阻塞其他线程。
  volatile表示变量在CPU的寄存器中是不确定的,必须从主存中读取。常见的作用是:保证多线程环境下变量的可见性和禁止指令重排序。
  synchronized关键字在JDK1.6进行了锁升级优化,执行效率有了显著提升,实际开发中还是使用synchronized的场景更多一些。
  两者的区别:

  1. volatile只能使用在变量上;而synchronized可以在类,变量,方法和代码块上。
  2. volatile只保证可见性;synchronized保证原子性与可见性。
  3. volatile禁用指令重排序;synchronized不会。
  4. volatile不会造成阻塞;synchronized会。

  以下从原子性、有序性、可见性3方面介绍。

  • 1、原子性
      synchronized可以保证原子性,因为同一时刻只有一个线程能执行synchronized修饰的代码。
      关于volatile是否能保证原子性,看个例子:
	    private static volatile int counter = 0;

	    public static void main(String[] args) {
	        for (int i = 0; i < 10; i++) {
	            Thread thread = new Thread(new Runnable() {
	                @Override
	                public void run() {
	                    for (int i = 0; i < 1000; i++)
	                        counter++;
	                }
	            });
	            thread.start();
	        }
	        try {
	            Thread.sleep(100);
	        } catch (InterruptedException e) {
	            e.printStackTrace();
	        }
	        System.out.println(counter);
	    }

  这段代码的意思很简单:开启10个线程,每个线程都自增1000次,如果不出现线程安全的问题最终的结果应该就是:10*1000 = 10000。但事实是每次的运行结果都小于10000。
  上篇文章已经说过,自增操作并不是原子性的。因此,在该例子中,一个线程A对变量进行了自增操作后,另一个线程B可能读取到的是自增前的值,这就会造成最后的结果小于10000。
  通过这个例子,可以看出volatile并不能保证复合操作(如count++)原子性

  • 2、有序性
      synchronized能够保证同一时刻只有一个线程执行临界区代码,synchronized语义就要求线程在访问读写共享变量时只能“串行”执行,因此synchronized能够保证有序性
      volatile可以通过禁止指令重排序的方式,保证有序性
  • 3、可见性
      synchronized和volatile都可以保证可见性

6.2 自旋锁一定比重量级锁效率高吗*

  自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说,性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
  如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用CPU做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又不能获取到CPU,造成CPU的浪费。

  • 9
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
线内存模型是指在多线程环境下,不同线程之间共享的内存模型。在多线程编程中,多个线程可以同时访问和修改同一个共享变量,但由于线程之间的并发执行,可能会出现一些并发问题,如数据竞争、原子性问题等,因此需要通过内存模型来规定多线程中共享变量的访问和修改规则,以保证线程之间的正确协作。 常用的多线内存模型有两种:顺序一致性内存模型Java内存模型Java Memory Model,JMM)。 顺序一致性内存模型是指对于每个线程来说,该线程的所有操作都是按照程序的顺序执行的,且所有线程之间的操作是按照全局顺序来执行的。这种内存模型相对简单,易于理解,但对程序的执行速度有一定的限制。 Java内存模型是针对Java语言的多线内存模型Java内存模型是基于顺序一致性内存模型的,但相对于顺序一致性内存模型Java内存模型允许一定程度上的重排序,以提高程序的执行效率。Java内存模型主要定义了共享变量的访问规则,如可见性、原子性等,并通过使用volatile关键字和synchronized关键字等机制来实现线程之间的同步与协作。 对于多线内存模型的理解和正确使用,对于编写高效且正确的多线程程序至关重要。在编写多线程程序时,需要根据具体需要选择合适的内存模型,并遵循相应的编程规范和约定,以确保多线程程序的正确性和可靠性。此外,还可以利用锁、原子类、线程安全的数据结构等工具来保证多线程程序的正确性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值