并发编程

并发编程

一、并发的三大问题

硬件的发展中,一直存在一个矛盾,CPU、内存、I/O设备的速度差异。

速度排序:CPU >> 内存 >> I/O设备

为了平衡这三者的速度差异,做了如下优化:

  1. CPU 增加了缓存,以均衡内存与CPU的速度差异;
  2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡I/O设备与CPU的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

1.可见性

可见性是什么?

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

为什么会有可见性问题?

对于如今的多核处理器,每颗CPU都有自己的缓存,而缓存仅仅对它所在的处理器可见,CPU缓存与内存的数据不容易保证一致。

为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中

缓存不能及时刷新导致了可见性问题。

public class Test {
public int a = 0;

public void increase() {
		a++;
	}

public static void main(String[] args) {
	final Test test = new Test();
	for (int i = 0; i < 10; i++) {
		new Thread() {
			public void run() {
				for (int j = 0; j < 1000; j++)
						test.increase();
				};
		}.start();
	}

	while (Thread.activeCount() > 1) {
			// 保证前面的线程都执行完
			Thread.yield();
		}
		System.out.println(test.a);
	}
}

目的:10个线程将inc加到10000。
结果:每次运行,得到的结果都小于10000。

原因分析:

img

假设线程1和线程2同时开始执行,那么第一次都会将a=0 读到各自的CPU缓存里,线程1执行a之后a=1,但是此时线程2是看不到线程1中a的值的,所以线程2里a=0,执行a后a=1。

线程1和线程2各自CPU缓存里的值都是1,之后线程1和线程2都会将自己缓存中的a=1写入内存,导致内存中a=1,而不是我们期望的2。所以导致最终 a 的值都是小于 10000 的。这就是缓存的可见性问题。

2.原子性

原子性是什么?

把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

在并发编程中,原子性的定义不应该和事务中的原子性(一旦代码运行异常可以回滚)一样。应该理解为:一段代码,或者一个变量的操作,在一个线程没有执行完之前,不能被其他线程执行。

为什么会有原子性问题?

线程是CPU调度的基本单位。CPU会根据不同的调度算法进行线程调度,将时间片分派给线程。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。多线程场景下,由于时间片在线程间轮换,就会发生原子性问题

如:对于一段代码,一个线程还没执行完这段代码但是时间片耗尽,在等待CPU分配时间片,此时其他线程可以获取执行这段代码的时间片来执行这段代码,导致多个线程同时执行同一段代码,也就是原子性问题。

线程切换带来原子性问题。

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

i = 0;		    // 原子性操作
j = i;		    // 不是原子性操作,包含了两个操作:读取i,将i值赋值给j
i++; 			// 不是原子性操作,包含了三个操作:读取i值、i + 1 、将+1结果赋值给i
i = j + 1;		// 不是原子性操作,包含了三个操作:读取j值、j + 1 、将+1结果赋值给i

原子性问题举例

还是上文中的代码,10个线程将inc加到10000。假设在保证可见性的情况下,仍然会因为原子性问题导致执行结果达不到预期。为方便看,把代码贴到这里:

public class Test {
	public int a = 0;
	public void increase() {
		a++;
	}

public static void main(String[] args) {
	final Test test = new Test();
	for (int i = 0; i < 10; i++) {
		new Thread() {
			public void run() {
				for (int j = 0; j < 1000; j++)
						test.increase();
				};
			}.start();
		}

		while (Thread.activeCount() > 1) {
			// 保证前面的线程都执行完
			Thread.yield();
		}
		System.out.println(test.a);
	}
}

目的:10个线程将inc加到10000。
结果:每次运行,得到的结果都小于10000。
    
原因分析:
首先来看a++操作,其实包括三个操作: 
①读取a=0; 
②计算0+1=1; 
③将1赋值给a; 
保证a++的原子性,就是保证这三个操作在一个线程没有执行完之前,不能被其他线程执行。    

实际执行时序图如下:

img

关键一步:线程2在读取a的值时,线程1还没有完成a=1的赋值操作,导致线程2的计算结果也是a=1。

问题在于没有保证a++操作的原子性。如果保证a的原子性,线程1在执行完三个操作之前,线程2不能执行a,那么就可以保证在线程2执行a++时,读取到a=1,从而得到正确的结果。

3.有序性

有序性:程序执行的顺序按照代码的先后顺序执行。

编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的Bug。

有序性问题举例

Java中的一个经典的案例:利用双重检查创建单例对象

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

在获取实例getInstance()的方法中,我们首先判断 instance是否为空,如果为空,则锁定 Singleton.class并再次检查instance是否为空,如果还为空则创建Singleton的一个实例。

看似很完美,既保证了线程完全的初始化单例,又经过判断instance为null时再用synchronized同步加锁。但是还有问题!

instance = new Singleton(); 创建对象的代码,分为三步:

①分配内存空间
②初始化对象Singleton
③将内存空间的地址赋值给instance

但是这三步经过重排之后:
①分配内存空间
②将内存空间的地址赋值给instance
③初始化对象Singleton

会导致什么结果呢?

线程A先执行getInstance()方法,当执行完指令②时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常。

执行时序图:

img

4.总结

并发编程的本质就是解决三大问题:原子性、可见性、有序性

原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。由于线程的切换,导致多个线程同时执行同一段代码,带来的原子性问题。

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存不能及时刷新导致了可见性问题。

有序性:程序执行的顺序按照代码的先后顺序执行。编译器为了优化性能而改变程序中语句的先后顺序,导致有序性问题。

启发:线程的切换、缓存及编译优化都是为了提高性能,但是引发了并发编程的问题。这也告诉我们技术在解决一个问题时,必然会带来另一个问题,需要我们提前考虑新技术带来的问题以规避风险。

二、重排序-可见性和有序性问题根源

1.重排序概念

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。处理器将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。

举例:如下代码执行过程中,程序不一定按照先A后B的顺序执行,经重排序之后可能按照先B后A的顺序执行。

int a = 1;// A
int b = 2;// B

2.重排序规则

重排序需要遵守一定规则,以保证程序正确执行。

重排序遵守数据依赖性

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

存在数据依赖性的三种情况:

① 写后读:a = 1;b = a; 写一个变量之后,再读这个位置。

② 写后写:a = 1;a = 2; 写一个变量之后,再写这个变量。

③ 读后写:a = b;b = 1;读一个变量之后,再写这个变量。

存在数据依赖关系的两个操作,不可以重排序。

数据依赖性只针对单个处理器中执行的指令序列和单个线程中执行的操作。

举例:

同一个线程中执行a=1;b=1; 不存在数据依赖性,可能重排序。

同一个线程中执行a=1;b=a; 存在数据依赖性,不可以重排序。

重排序遵守as-if-serial 语义

as-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

举例,以计算圆的面积为例:

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

A和B重排序之后,程序的执行结果不会改变,所以允许A、B重排序。A和C重排序之后,程序的执行结果会改变,所以不允许A、C重排序。

3.重排序带来的问题

重排序可以提高程序执行的性能,但是代码的执行顺序改变,可能会导致多线程程序出现可见性问题和有序性问题

举例:

初始状态:a = b = 0;x = y = 0;
Processor A:
	a = 1; // A1
	x = b; // A2
Processor B:
	b = 2; // B1
	y = a; // B2

如上代码,Processor A和Processor B同时执行,最终却可能得到x = y = 0的结果。

原因分析:

img

第一步执行A1/B1将a=1写到缓冲区,此时写缓冲区还在等待其他写操作,不执行A3,所以内存中的a=0;

第二步执行A2/B2,处理器读取内存中的a,得到a=0;

虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了

4. JMM(内存模型)如何解决重排序问题

JMM处理重排序问题:

1)对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

2)对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序。

3)JMM根据代码中的关键字(如:synchronized、volatile)和J.U.C包下的一些具体类来插入内存屏障。

JMM 把内存屏障指令分为下列四类:

img

Store:数据对其他处理器可见(即:刷新到内存中)

Load:让缓存中的数据失效,重新从主内存加载数据

5.总结

  • 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。

  • 从Java源代码到最终实际执行,要经历三种重排序:编译器优化的重排序、指令级并行的重排序、内存系统的重排序。

  • as-if-serial语义要求:不管怎么重排序,程序的执行结果不能被改变。

  • 存在数据依赖关系的两个操作,不可以重排序。

  • 重排序可能会导致多线程程序出现可见性问题和有序性问题。

  • JMM编译时在当位置会插入内存屏障指令来禁止特定类型的重排序。

三、Java内存模型详解

1.JMM抽象结构模型

JMM定义了线程和主内存之间的抽象关系:

  1. 线程之间的共享变量存储在主内存中
  2. 每个线程都有一个私有的本地内存,本地内存中存储了该线程用以读/写共享变量的副本
  3. 共享变量:堆内存在线程之间共享,存储在堆内存中所有实例域、静态域和数组元素都是共享变量

img

2.线程之间通信

线程A与线程B通信:

  1. 线程A把本地内存A中的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

从整体来看,这个过程就是线程A在向线程B发送消息。这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

举例:

public class JMMTest {
    static int a = 0;// 主内存中的共享变量
    public static void main(String[] args) {
        new Thread() {
            public void run() {
                a = 1;// 线程本地内存中操作共享变量a,并将a=1刷新到猪内存中
                while(true) {// 测试用,为了保持线程运行
                }
            };
        }.start();
        
        new Thread() {
            public void run() {
                System.out.println(a);// 线程到主内存中读取变量a
                while(true) {
                }
            };
        }.start();
    }
}

两个线程之间的通信过程如下图:

img

3. JMM解决可见性和有序性问题

  1. 要求程序员都去搞懂重排序以及JMM内存屏障再去编程是不现实的。
  2. JMM提供了简单易懂的happens-before原则,并向程序员保证执行并发程序会遵守happens-before原则。
  3. 程序员只需理解happens-before原则,按照happens-before原则写并发代码,就能保证内存可见性和有序性。

4.JMM的设计

1.程序员对内存模型的使用

程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。

JMM向程序员提供的happens-before规则,简单易懂且提供了足够强的内存可见性保证。程序员可以把happens-before规则当做强内存模型看待。

2.编译器和处理器对内存模型的实现

编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

JMM遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

例如这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

1.如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。
2.如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。

如图,程序员、happens-before、JMM之间的关系:

img

3.happens-before

一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

两个操作可以是单线程或多线程,happens-before解决的就是多线程内存可见性问题。区分数据依赖性和as-if-seial针对单线程。

happens-before原则定义如下:

1)一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

happens-before原则规则:

1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
2)锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8)对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

JMM与原子性问题

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,需要通过互斥加锁synchronized和Lock来实现。

5.总结

JMM定义了线程和主内存之间的抽象关系,共享变量存储在主内存中,线程本地内存中存储了该线程用以读/写共享变量的副本。

JMM向程序员提供的happens-before规则来解决可见性和有序性问题。

一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

四、深入理解volatile

1.保证可见性

volatile保证了不同线程对volatile修饰变量进行操作时的可见性。

对一个volatile变量的读,(任意线程)总是能看到对这个volatile变量最后的写入。

  1. 一个线程修改volatile变量的值时,该变量的新值会立即刷新到主内存中,这个新值对其他线程来说是立即可见的。
  2. 一个线程读取volatile变量的值时,该变量在本地内存中缓存无效,需要到主内存中读取。

举例:

中断线程时常采用这种标记办法。

boolean stop = false;// 是否中断线程1标志

//Tread1
new Thread() {
    public void run() {
        while(!stop) {
          doSomething();
        }
    };
}.start();

//Tread2
new Thread() {
    public void run() {
        stop = true;
    };
}.start();

目的: Tread2设置stop=true时,Tread1读取到stop=true,Tread1中断执行。

问题: 虽然大多数时候可以达到中断线程1的目的,但是有可能发生Tread2设置stop=true后,Thread1未被中断的情况,而且这种情况引发的都是比较严重的线上问题,排查难度很大。

问题分析: Tread2设置stop=true时,并未将stop=true刷到主内存,导致Tread1到主内存中读取到的仍然是stop=false,Tread1就会继续执行。也就是有内存可见性问题。

解决: stop变量用volatile修饰。
Tread2设置stop=true时,立即将volatile修饰的变量stop=true刷到主内存;
Tread1读取stop的值时,会到主内存中读取最新的stop值。    

2. 保证有序性

volatile关键字能禁止指令重排序,保证了程序会严格按照代码的先后顺序执行,即保证了有序性。

volatile的禁止重排序规则:

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

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

3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

举例:

boolean inited = false;// 初始化完成标志

//线程1:初始化完成,设置inited=true
new Thread() {
    public void run() {
        context = loadContext();   //语句1
        inited = true;             //语句2
    };
}.start();

//线程2:每隔1s检查是否完成初始化,初始化完成之后执行doSomething方法
new Thread() {
    public void run() {
        while(!inited){
          Thread.sleep(1000);
        }
        doSomething(context);
    };
}.start();

目的: 线程1初始化配置,初始化完成,设置inited=true。线程2每隔1s检查是否完成初始化,初始化完成之后执行doSomething方法。

问题: 线程1中,语句1和语句2之间不存在数据依赖关系,JMM允许这种重排序。如果在程序执行过程中发生重排序,先执行语句2后执行语句1,会发生什么情况?

当线程1先执行语句2时,配置并未加载,而inited=true设置初始化完成了。线程2执行时,读取到inited=true,直接执行doSomething方法,而此时配置未加载,程序执行就会有问题。

解决: volatile修饰inited变量。

volatile修饰inited,“当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。”,保证线程1中语句1与语句2不能重排序。

3. 不保证原子性

volatile是不能保证原子性的。

原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。

举例:

public class VolatileTest {
    public volatile int a = 0;

    public void increase() {
        a++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 1000; j++)
                        test.increase();
                };
            }.start();
        }

        while (Thread.activeCount() > 1) {
            // 保证前面的线程都执行完
            Thread.yield();
        }
        System.out.println(test.a);
    }
}

目的: 10个线程将inc加到10000。

结果: 每次运行,得到的结果都小于10000。

原因分析:

首先来看a++操作,其实包括三个操作:

①读取a=0;

②计算0+1=1;

③将1赋值给a;
保证a++的原子性,就是保证这三个操作在一个线程没有执行完之前,不能被其他线程执行。

一个可能的执行时序图如下:

img

关键一步:线程2在读取a的值时,线程1还没有完成a=1的赋值操作,导致线程2读取到当前a=0,所以线程2的计算结果也是a=1。

问题在于没有保证a操作的原子性。如果保证a的原子性,线程1在执行完三个操作之前,线程2不能执行a++,那么就可以保证在线程2执行a++时,读取到a=1,从而得到正确的结果。

解决:

  1. synchronized保证原子性,用synchronized修饰increase()方法。
  2. CAS来实现原子性操作,AtomicInteger修饰变量a。

4.volatile实现原理

1.volatile保证有序性原理

前文介绍过,JMM通过插入内存屏障指令来禁止特定类型的重排序。

java编译器在生成字节码时,在volatile变量操作前后的指令序列中插入内存屏障来禁止特定类型的重排序。

volatile内存屏障插入策略:

在每个volatile写操作的前面插入一个StoreStore屏障。

在每个volatile写操作的后面插入一个StoreLoad屏障。

在每个volatile读操作的后面插入一个LoadLoad屏障。

在每个volatile读操作的后面插入一个LoadStore屏障。

img

Store:数据对其他处理器可见(即:刷新到内存中)
Load:让缓存中的数据失效,重新从主内存加载数据

2.volatile保证可见性原理

volatile内存屏障插入策略中有一条,“在每个volatile写操作的后面插入一个StoreLoad屏障”。

StoreLoad屏障会生成一个Lock前缀的指令,Lock前缀的指令在多核处理器下会引发了两件事:

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

volatile内存可见的写-读过程:

  1. volatile修饰的变量进行写操作。
  2. 由于编译期间JMM插入一个StoreLoad内存屏障,JVM就会向处理器发送一条Lock前缀的指令。
  3. Lock前缀的指令将该变量所在缓存行的数据写回到主内存中,并使其他处理器中缓存了该变量内存地址的数据失效。
  4. 当其他线程读取volatile修饰的变量时,本地内存中的缓存失效,就会到到主内存中读取最新的数据。

5.总结

并发编程中,常用volatile修饰变量以保证变量的修改对其他线程可见。

volatile可以保证可见性和有序性,不能保证原子性。

volatile是通过插入内存屏障禁止重排序来保证可见性和有序性的。

五、理解final关键字

1.final变量

final变量只能被赋值一次,赋值后值不再改变。(final要求地址值不能改变

当final修饰一个基本数据类型时,表示该基本数据类型的值一旦在初始化后便不能发生变化;

如果final修饰一个引用类型时,则在对其初始化之后便不能再让其指向其他对象了,但该引用所指向的对象的内容是可以发生变化的

本质上是一回事,因为引用的值是一个地址,final要求地址值不发生变化。

当final修饰一个基本数据类型时,表示该基本数据类型的值一旦在初始化后便不能发生变化;如果final修饰一个引用类型时,则在对其初始化之后便不能再让其指向其他对象了,但该引用所指向的对象的内容是可以发生变化的。本质上是一回事,因为引用的值是一个地址,final要求地址值不发生变化。

final成员变量:两种初始化方式,一种是在变量声明的时候初始化;第二种是在声明变量的时候不赋初值,但是要在这个变量所在的类的所有的构造函数中对这个变量赋初值。

final方法

final修饰的方法在编译阶段被静态绑定(static binding),不能被重写。

final方法比非final方法要快,因为在编译的时候已经静态绑定了,不需要在运行时再动态绑定。(注:类的private方法会隐式地被指定为final方法)

final类

final修饰的类不能被继承。

final类中的成员变量可以根据需要设为final,但是要注意final类中的所有成员方法都会被隐式地指定为final方法。

在使用final修饰类的时候,要注意谨慎选择,除非这个类真的在以后不会用来继承或者出于安全的考虑,尽量不要将类设计为final类。

关于final的几个重要知识点

  • final关键字可以提高性能,JVM和Java应用都会缓存final变量,JVM会对方法、变量及类进行优化。
  • 在匿名类中所有变量都必须是final变量。
  • 接口中声明的所有变量本身是final的。
  • final和abstract这两个关键字是反相关的,final类就不可能是abstract的
  • 按照Java代码惯例,final变量就是常量,而且通常常量名要大写
  • final变量可以安全的在多线程环境下进行共享,而不需要额外的同步开销

2. 并发编程中的final

1.写final域

在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

编译器会在final域的写之后,插入一个StoreStore屏障,这个屏障可以禁止处理器把final域的写重排序到构造函数之外。

解释:保证先写入对象的final变量,后调用该对象引用。

举例

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

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

    public static void writer() {
		 // 两个操作:
		 // 1)构造一个FinalExample类型的对象,①写普通域a=1,②写final域b=2
		 // 2)③把这个对象的引用赋值给引用变量finalDemo
        finalDemo = new FinalDemo();
    }

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

假设一个线程A执行writer()方法,随后另一个线程B执行reader()方法。通过这两个线程的交互来说明写final域的规则。下图是一种可能的执行时序:

img

写普通域的操作可以被编译器重排序到了构造函数,①写普通域和③把这个对象的引用赋值给引用变量finalDemo重排序,导致读线程B错误的读取了普通变量a的值。

写final域的操作不能重排序到了构造函数外,②写final域和③把这个对象的引用赋值给引用变量finalDemo不能重排序,读线程B正确的读取了final变量b的值。

2.读final域

初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

编译器会在读final域操作的前面插入一个LoadLoad屏障,这个屏障可以禁止读对象引用和读该对象final域重排序。

解释:先读对象的引用,后读该对象的final变量。

举例:

还是上面那段代码,假设一个线程A执行writer()方法,随后另一个线程B执行reader()方法。下图是一种可能的执行时序:

img

读对象的普通域的操作可以被重排序到读对象引用之前,⑤读普通域与④读对象引用重排序,读普通域a时,a没有被写线程A写入,导致错误的读取。

读final域的操作不可以被重排序到读对象引用之前,④读对象引用和⑥读final域不能重排序,读取该final域b时已经被A线程初始化过了,不会有问题。

3.final域为引用类型

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

解释:

  1. 注意是增加了一条约束,所以以上两条约束都还生效。
  2. 保证先写入对象的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执行writerOne()方法,执行后线程B执行writerTwo()方法,执行后线程C执行reader()方法。下面是一种可能的线程执行时序:

img

1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。

由写final域的重排序规则“写final域的操作不能重排序到了构造函数外”可知,1和3是不能重排序的。

引用类型final域的重排序规则“final引用的对象的成员域的写入不能重排序到了构造函数外”,保证了2和3不能重排序。所以线程C至少能看到数组下标0的值为1。

写线程B对数组元素的写入,读线程C不一定能看到。因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。

3.总结

final基础应用

  • final修饰的变量地址值不能改变。
  • final修饰的方法不能被重写。
  • final修饰的类不能被继承。

并发编程中final可以禁止特定的重排序。

  • final保证先写入对象的final变量,后调用该对象引用。
  • final保证先读对象的引用,后读该对象的final变量。
  • final保证先写入对象的final变量的成员变量,后调用该对象引用。

六、synchronized原理

6.1. synchronized使用

6.1.1 线程安全问题

并发编程中,当多个线程同时访问同一个资源的时候,就会存在线程安全问题。

由于每个线程执行的过程是不可控的,所以很可能导致最终的结果与实际期望的结果相违背或者直接导致程序出错。

举例:

public classVolatileTest {
    publicint inc = 0;
 
    public void increase() {
       inc++;
    }
 
    public static void main(String[] args) {
       final VolatileTest test = newVolatileTest();
       for (int i = 0; i < 10; i++) {
           new Thread() {
              public void run() {
                  for (int j = 0; j < 1000;j++)
                     test.increase();
              };
           }.start();
       }
 
       while (Thread.activeCount() > 1)
           // 保证前面的线程都执行完
           Thread.yield();
       System.out.println(test.inc);
    }
}

目的:test.inc = 10000

结果:多次执行得到的结果都小于10000

分析:线程安全问题。

当某个时间test.inc=2,有多个线程同时读取到test.inc=2,并且同时执行加1操作,这些线程的此次操作都执行之后test.inc=3。也就是说执行了多个加1操作,却只将结果增加了1,所以导致最终结果始终小于10000。

基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。
在Java中,提供了两种方式来实现同步互斥访问:synchronized和Lock。

Java中用synchronized标记同步块。

  • 同步块在Java中是同步在某个对象上(监视器对象)。
  • 所有同步在一个对象上的同步块在同一时间只能被一个线程进入并执行操作。
  • 所有其他等待进入该同步块的线程将被阻塞,直到执行该同步块中的线程退出。

6.1.2 synchronized用法

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的class对象
  • 同步方法块,锁是括号里面的对象

举例:

publicclass MyClass{
    int count;
   
    // 1.实例方法
    public synchronized void add(int value){
        count += value;
    }
   
    // 2.实例方法中的同步块 (等价于1)
    public void add(int value){
        synchronized(this){
            count += value;
        }
    }
   
    // 3.静态方法
    public static synchronized void add(intvalue){
         count += value;
    }
   
    // 4.静态方法中的同步块 (等价于3)
    public static void add(int value){
        synchronized(MyClass.class){
            count += value;
        }
    }
}

6.2.原理探究

如下代码,利用javap工具查看生成的class文件信息来分析Synchronize的实现。

代码:

publicclass synchronized Test {
    // 同步代码块
    public void doSth1(){
       synchronized (synchronizedTest.class){
           System.out.println("HelloWorld");
       }
    }
    // 同步方法
    public synchronized void doSth2(){
        System.out.println("HelloWorld");
    }
}

使用javap对class文件进行反编译后结果:

javap命令:

D:\install\java\jdk8\bin\javap.exe -v .\synchronizedTest.class

img

img

从反编译后的结果中可以看到:对于同步方法,JVM采用ACC_synchronized标记符来实现同步。对于同步代码块。JVM采用monitorenter、monitorexit两个指令来实现同步。

6.2.1.同步代码块

JVM采用monitorenter、monitorexit两个指令来实现同步。
查询JVM规范The Java® Virtual Machine Specification[1]中关于monitorenter和monitorexit的介绍:

img

大致内容如下:

  1. 可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。
  2. 每个对象维护着一个记录着被锁次数的计数器。
  3. 未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为1,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。
  4. 当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

6.2.2.同步方法

JVM采用ACC_synchronized标记符来实现同步。

查询JVM规范The Java® Virtual Machine Specification[2]中关于方法级同步的介绍:

img

大致内容如下:

  1. 方法级的同步是隐式的。同步方法的常量池中会有一个ACC_synchronized标志。
  2. 当某个线程要访问某个方法的时候,会检查是否有ACC_synchronized,如果有设置,则需要先获得监视器锁(monitor),然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。
  3. 值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

6.3.Monitor

无论是同步方法还是同步代码块都是基于监视器Monitor实现的。

6.3.1.Monitor是什么?

所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

每个对象都存在着一个Monitor与之关联,对象与其Monitor之间的关系有存在多种实现方式,如Monitor可以与对象一起创建销毁。

6.3.2.Moniter如何实现线程的同步?

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)。

ObjectMonitor中有几个关键属性:

_owner:指向持有ObjectMonitor对象的线程

_WaitSet:存放处于wait状态的线程队列

_EntryList:存放处于等待锁block状态的线程队列

_recursions:锁的重入次数

_count:用来记录该线程获取锁的次数

  • 线程T等待对象锁:_EntryList中加入T。
  • 线程T获取对象锁:_EntryList移除T,_owner置为T,计数器_count加1
  • 线程T中锁对象调用wait():_owner置为null,计数器_count减1,_WaitSet中加入T等待被唤醒。
  • 持有对象锁的线程T执行完毕:复位变量的值,以便其他线程进入获取monitor。

img

6.4. 总结

多并发编程中通过同步互斥访问临界资源来解决线程安全问题,Java中常用synchronized标记同步块达到加锁的目的。

synchronized用法有两种,修饰方法和修饰同步代码块。

synchronized的实现原理:每一个Java对象都会关联一个Monitor,通过Monitor对线程的操作实现synchronized对象锁。

并发编程中synchronized可以保证原子性、可见性、有序性。

七、synchronized锁优化

7.1.为什么需要优化

synchronized监视器锁在互斥同步上对性能的影响很大。

Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,状态转换需要花费很多的处理器时间。

所以频繁的通过Synchronized实现同步会严重影响到程序效率,这种锁机制也被称为重量级锁,为了减少重量级锁带来的性能开销,JDK对Synchronized进行了种种优化。

7.2.自旋锁和适应自旋锁

大多数情况下,线程持有锁的时间都不会太长,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

1)自旋锁

当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。

在经过若干次循环后,如果得到锁,就顺利进入临界区;如果还不能获得锁,那就会将线程在操作系统层面挂起。

2)自旋锁和阻塞最大的区别

主要区别:是不是放弃处理器的执行时间。

阻塞放弃了CPU时间,进入了等待区,等待被唤醒。响应慢。自旋锁一直占用CPU时间,时刻检查共享资源是否可以被访问,所以响应速度更快。

3)缺点

如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是如果持有锁的线程占用锁时间较长,等待锁的线程自旋一定次数后还是拿不到锁而被阻塞,那么自旋就白白浪费了CPU的资源。

所以自旋的次数直接决定了自旋锁的性能。JDK自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

4)自适应自旋锁

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。

如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

7.3. 锁消除

如果JVM检测到某段代码不可能存在共享数据竞争,JVM会对这段代码的同步锁进行锁消除。

在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。

举例:

public void vectorTest() {
    Vector<String> vector = new Vector<String>();
    for (int i = 0; i < 10; i++) {
        vector.add(i + "");
    }

    System.out.println(vector);
}

Vector的add方法是Synchronized修饰的。

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

7.4. 锁粗化

很多时候,我们提倡尽量减小锁的粒度,可以避免不必要的阻塞。 让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是如果在一段代码中连续的用同一个监视器锁反复的加锁解锁,甚至加锁操作出现在循环体中的时候,就会导致不必要的性能损耗,这种情况就需要锁粗化。

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

举例:

for(int i=0;i<100000;i++){
    synchronized(this){
        do();
}
    
会被粗化成:
    
synchronized(this){
    for(int i=0;i<100000;i++){
        do();
}    

7.5.知识补充:Java对象头

对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

普通对象的对象头包括两部分:Mark Word和Class Metadata Address (类型指针),如果是数组对象还包括一个额外的Array length数组长度部分。

Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。

Class Metadata Address:类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。

img

Mark Word

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

对mark word的设计方式上,非常像网络协议报文头:将mark word划分为多个比特位区间,并在不同的对象状态下赋予比特位不同的含义。

下图描述了在32位虚拟机上,在对象不同状态时mark word各个比特位区间的含义。

img

7.6.偏向锁、轻量级锁、重量级锁

从Java对象头的Mark word中可以看到,synchronized锁一共具有四种状态:无锁、偏向锁、轻量级锁、重量级锁。

偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

偏向锁

目的:大多数情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,所以引入偏向锁让线程获得锁的代价更低。

偏向锁认为环境中不存在竞争情况,锁只被一个线程持有,一旦有不同的线程获取或竞争锁对象,偏向锁就升级为轻量级锁。

偏向锁在无多线程竞争的情况下可以减少不必须要的轻量级锁执行路径。

轻量级锁

目的:在大多数情况下同步块并不会出现竞争情况,大部分情况是不同线程交替持有锁,所以引入轻量级锁可以减少重量级锁对线程的阻塞带来的开销。

轻量级锁认为环境中线程几乎没有对锁对象的竞争,即使有竞争也只需要稍微等待(自旋)下就可以获取锁,但是自旋次数有限制,如果超过该次数,则会升级为重量级锁。

重量级锁

监视器锁Monitor

7.7.锁的膨胀过程

synchronized锁膨胀过程就是无锁 → 偏向锁 → 轻量级锁 → 重量级锁的一个过程。这个过程是随着多线程对锁的竞争越来越激烈,锁逐渐升级膨胀的过程。

如下分析,从一个没有线程访问的锁逐渐升级到重量级锁的过程:

1)一个锁对象刚刚开始创建的时候,没有任何线程来访问它,此时线程状态为无锁状态。Mark word(锁标志位-01 是否偏向-0)

2)当线程A来访问这个对象锁时,它会偏向这个线程A。线程A检查Mark word(锁标志位-01 是否偏向-0)为无锁状态。此时,有线程访问锁了,无锁升级为偏向锁,Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID)

3)当线程A执行完同步块时,不会主动释放偏向锁。**持有偏向锁的线程执行完同步代码后不会主动释放偏向锁,而是等待其他线程来竞争才会释放锁。**Mark word不变(锁标志位-01,是否偏向-1,线程ID-线程A的ID)

4)当线程A再次获取这个对象锁时,检查Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A,可以直接执行同步代码。这样偏向锁保证了总是同一个线程多次获取锁的情况下,每次只需要检查标志位就行,效率很高

5)当线程A执行完同步块之后,线程B获取这个对象锁 检查Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A。有不同的线程获取锁对象,偏向锁升级为轻量级锁,并由线程B获取该锁。

6)当线程A正在执行同步块时,也就是正持有偏向锁时,线程B获取来这个对象锁。

检查Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A。

线程A撤销偏向锁:

  1. 等到全局安全点执行撤销偏向锁,暂停持有偏向锁的线程A并检查程A的状态;

  2. 如果线程A不处于活动状态或者已经退出同步代码块,则将对象锁设置为无锁状态,然后再升级为轻量级锁。由线程B获取轻量级锁。

  3. 如果线程A还在执行同步代码块,也就是线程A还需要这个对象锁,则偏向锁膨胀为轻量级锁。

线程A膨胀为轻量级锁过程:

  1. 在升级为轻量级锁之前,持有偏向锁的线程(线程A)是暂停的

  2. 程A栈帧中创建一个名为锁记录的空间(Lock Record)

  3. 锁对象头中的Mark Word拷贝到线程A的锁记录中

  4. Mark Word的锁标志位变为00,指向锁记录的指针指向线程A的锁记录地址,Mark word(锁标志位-00,其他位-线程A锁记录的指针)

  5. 当原持有偏向锁的线程(线程A)获取轻量级锁后,JVM唤醒线程A,线程A执行同步代码块

7)线程A持有轻量级锁,线程A执行完同步块代码之后,一直没有线程来竞争对象锁,正常释放轻量级锁。释放轻量级锁操作:CAS操作将线程A的锁记录(Lock Record)中的Mark Word替换回锁对象头中。

8)线程A持有轻量级锁,执行同步块代码过程中,线程B来竞争对象锁。

Mark word(锁标志位-00,其他位-线程A锁记录的指针)

  1. 线程B会先在栈帧中建立锁记录,存储锁对象目前的Mark Word的拷贝
  2. 线程B通过CAS操作尝试将锁对象的Mark Word的指针指向线程B的Lock Record,如果成功,说明线程A刚刚释放锁,线程B竞争到锁,则执行同步代码块。
  3. 因为线程A一直持有锁,大部分情况下CAS是会失败的。CAS失败之后,线程B尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。
  4. 线程B不会一直自旋下去,如果自旋了一定次数后还是失败,线程B会被阻塞,等待释放锁后唤醒。此时轻量级锁就会膨胀为重量级锁。Mark word(锁标志位-10,其他位-重量级锁monitor的指针)
  5. 线程A执行完同步块代码之后,执行释放锁操作,CAS 操作将线程A的锁记录(Lock Record)中的Mark Word 替换回锁对象对象头中,因为对象头中已经不是原来的轻量级锁的指针了,而是重量级锁的指针,所以CAS操作会失败。
  6. 释放轻量级锁CAS操作替换失败之后,需要在释放锁的同时需要唤醒被挂起的线程B。线程B被唤醒,获取重量级锁monitor

7.8.总结

synchronized实现同步会严重影响到程序效率,为了减少重量级锁带来的性能开销,JDK对Synchronized进行了优化。

自旋锁:当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。如果此时锁释放,当前线程就可以获得锁。

锁消除:如果JVM检测到某段代码不可能存在共享数据竞争,会对这段代码的同步锁进行锁消除。

锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

synchronized锁膨胀过程就是无锁 → 偏向锁 → 轻量级锁 → 重量级锁的一个过程。这个过程是随着多线程对锁的竞争越来越激烈,锁逐渐升级膨胀的过程。

八、CAS

CAS,Compare And Swap,即比较并交换。Doug lea 大神在同步组件中大量使用 CAS 技术鬼斧神工地实现了 Java 多线程的并发操作。整个 AQS 同步组件、Atomic 原子类操作等等都是以 CAS 实现的。可以说 CAS 是整个 J.U.C 的基石。

CAS 比较交换的过程 CAS(V,A,B):
V-一个内存地址存放的实际值、A-旧的预期值、B-即将更新的值,当且仅当预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做,并返回 false。

8.1.CAS VS synchronized

synchronized 是线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的之后会阻塞其他线程获取该锁。

CAS(无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,所以出现冲突时就不会阻塞其他线程的操作,而是重试当前操作直到没有冲突为止。

8.2.如何用 CAS 解决原子性问题

如下代码,目的是启动 10 个线程,每个线程将 a 累加 1000 次,最终得到 a=10000。

public class CASTest {
	public int a = 0;

	public void increase() {
		a++;
	}

	public static void main(String[] args) {
		final CASTest test = new CASTest();
		for (int i = 0; i < 10; i++) {
			new Thread() {
				public void run() {
					for (int j = 0; j < 1000; j++)
						test.increase();
				};
			}.start();
		}

		while (Thread.activeCount() > 1) {
			// 保证前面的线程都执行完
			Thread.yield();
		}
		System.out.println(test.a);
	}
}

结果:每次运行结果都小于 10000。

原因分析:

当线程 1 将 a 加到 2 时,a=2 刷新到主内存;

线程 2 执行增加运算时,到主内存读取 a=2,此时线程 3 也要执行增加运算,也到主内存中读取到 a=2;

线程 2 和线程 3 执行的都是 a=2+1,将 a=3 刷新到主内存。

相当于两次加 1 运算只将 a 增加了 1,也就是说存在执行了多次加 1 运算却只是将 a 增加 1 的情况,所以 10000 次加 1 运算,得到的结果会小于 10000。

原子性问题,解决方案 synchronized 和 CAS。

解决方案一:synchronized 加锁

public synchronized void increase() {
    a++;
}

通过 synchronized 加锁之后,每次只能有一个线程访问 increase()方法,能够保证最终得到 10000。但是 synchronized 加锁是个重量级操作,程序执行效率很低。

解决方案二:CAS

public AtomicInteger a = new AtomicInteger();
public void increase() {
    a.getAndIncrement();
}

利用 CAS,保证 a=a+1 是原子性操作,最终得到结果 10000。

8.3.CAS 原理

探究 CAS 原理,其实就是探究上个例子中 a.getAndIncrement()如何保证 a=a+1 是原子性操作,先通过源码看下。

AtomicInteger 类结构

public class AtomicInteger extends Number implements java.io.Serializable {
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
}
  1. Unsafe 是 CAS 的核心类,由于 Java 方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据。
  2. 变量 valueOffset 表示该变量值在内存中的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据的原值。
  3. 变量 value 用 volatile 修饰,保证了多线程之间的内存可见性。

a.getAndIncrement()的实现如下

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

getIntVolatile(var1, var2):根据对象 var1 和对象中该变量地址 var2,获取变量的值 var5。

this.compareAndSwapInt(var1, var2, var5, var5 + var4);

  1. 根据对象 var1 和对象中该变量地址 var2 获取变量当前的值 value
  2. 比较 value 跟 var5,如果 value==var5,则 value=var5+var4 并返回 true。这步操作就是比较和替换操作,是原子性的
  3. 如果 value!=var5,则返回 false,再去自旋循环到下一次调用 compareAndSwapInt 方法。

可见,getAndIncrement()的原子性是通过 compareAndSwapInt()中的第二步比较和替换保证的,那么 compareAndSwapInt()又是怎么保证原子性的呢?

compareAndSwapInt 方法是 JNI(Java Native InterfaceJAVA 本地调用),java 通过 C 来调用 CPU 底层指令实现的。

compareAndSwapInt 方法中的比较替换操作之前插入一个 lock 前缀指令,这个指令能过确保后续操作的原子性。

lock 前缀指令确保后续指令执行的原子性:

在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。
在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。

CPU 提供了两种方法来实现多处理器的原子操作:总线加锁和缓存加锁。

  • 总线加锁:总线加锁就是就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显得有点儿霸道,不厚道,他把 CPU 和内存之间的通信锁住了,在锁定期间,其他处理器都不能其他内存地址的数据,其开销有点儿大。所以就有了缓存加锁。
  • 缓存加锁:其实针对于上面那种情况我们只需要保证在同一时刻对某个内存地址的操作是原子性的即可。缓存加锁就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不在输出 LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当 CPU1 修改缓存行中的 i 时使用缓存锁定,那么 CPU2 就不能同时缓存了 i 的缓存行。

8.3.1.比较替换操作过程分析

  1. 假设线程 A 和线程 B 同时调用 a.getAndIncrement()-->getAndIncrement()-->getAndAddInt(),AtomicInteger 里面的 value 原始值为 3,即主内存中 AtomicInteger 的 value 为 3,且线程 A 和线程 B 各自持有一份 value 的副本,值为 3。
  2. 线程 A 通过 getIntVolatile(var1, var2)拿到 value 值 3,线程 B 也通过 getIntVolatile(var1, var2)拿到 value 值 3,线程 A 和线程 B 同时调用 compareAndSwapInt()。
  3. 线程 A 执行 compareAndSwapInt()方法比较和替换时,其他 CPU 无法访问该变量的内存,所以线程 B 不能进行比较替换。线程 A 成功修改内存值为 4,返回 true,执行结束。
  4. 线程 B 恢复,执行 compareAndSwapInt()方法比较和替换,发现内存的实际值 4 跟自己期望值 3 不一致,说明该值已经被其它线程提前修改过了,返回 false,自旋进入 while 循环,再通过 getIntVolatile(var1, var2)方法获取 value 值 4,执行 compareAndSwapInt()比较替换,直到成功。

8.4.CAS 的问题

8.4.1.ABA 问题

CAS 需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是 A,变成了 B,然后又变成了 A,那么在 CAS 检查的时候会认为没有改变,但是实质上它已经发生了改变,这就是 ABA 问题。

解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径 A->B->A 就变成了 1A->2B->3A。

在 java 1.5 后的 atomic 包中提供了 AtomicStampedReference 来解决 ABA 问题,解决思路就是这样的。

8.4.2.自旋时间过长

使用 CAS 时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果自旋 CAS 长时间地不成功,则会给 CPU 带来非常大的开销。

优化:限制 CAS 自旋的次数,例如 BlockingQueue 的 SynchronousQueue。

8.4.3.只能保证一个共享变量的原子操作

当对一个共享变量执行操作时 CAS 能保证其原子性,如果对多个共享变量进行操作,CAS 就不能保证其原子性。

解决方案:把多个变量整成一个变量

  1. 利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量,然后将这个对象做 CAS 操作就可以保证其原子性。atomic 中提供了 AtomicReference 来保证引用对象之间的原子性。
  2. 利用变量的高低位,如 JDK 读写锁 ReentrantReadWriteLock 的 state,高 16 位用于共享模式 ReadLock,低 16 位用于独占模式 WriteLock。

8.5.Java 中的原子操作类

在 J.U.C 下的 atomic 包提供了一系列原子操作类。

1)基本数据类型的原子操作类

AtomicInteger、AtomicLong、AtomicBoolean

以 AtomicInteger 为例总结一下常用的方法:

addAndGet(int delta) :以原子方式将输入的数值delta与实例中原本的值相加,并返回最后的结果;
incrementAndGet() :以原子的方式将实例中的原值进行加1操作,并返回最终相加后的结果;
getAndSet(int newValue):将实例中的值更新为新值newValue,并返回旧值;
getAndIncrement():以原子的方式将实例中的原值加1,返回的是自增前的旧值;

用法:

public class AtomicDemo {
    private static AtomicInteger atomicInteger = new AtomicInteger(1);

    public static void main(String[] args) {
        System.out.println(atomicInteger.getAndIncrement());
        System.out.println(atomicInteger.get());
    }
}

输出结果:
1
2

2)数组类型的原子操作类

AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray(引用类型数组)。

以 AtomicIntegerArray 来总结下常用的方法:

addAndGet(int i, int delta):以原子更新的方式将数组中索引为i的元素与输入值delta相加;
getAndIncrement(int i):以原子更新的方式将数组中索引为i的元素自增加1;
compareAndSet(int i, int expect, int update):将数组中索引为i的位置的元素进行更新;

用法:

AtomicIntegerArray 与 AtomicInteger 的方法基本一致,只不过在 AtomicIntegerArray 的方法中会多一个指定数组索引位 i。

通过 getAndAdd 方法将位置为 1 的元素加 5,从结果可以看出索引为 1 的元素变成了 7,该方法返回的也是相加之前的数为 2。

public class AtomicDemo {
    private static int[] value = new int[]{1, 2, 3};
    private static AtomicIntegerArray integerArray = new AtomicIntegerArray(value);

    public static void main(String[] args) {
        //对数组中索引为1的位置的元素加5
        int result = integerArray.getAndAdd(1, 5);
        System.out.println(integerArray.get(1));
        System.out.println(result);
    }
}

输出结果:

7
2

3)引用类型的原子操作类

AtomicReference

用法:

public class AtomicDemo {

    private static AtomicReference<User> reference = new AtomicReference<>();

    public static void main(String[] args) {
        User user1 = new User("a", 1);
        reference.set(user1);
        User user2 = new User("b",2);
        User user = reference.getAndSet(user2);
        System.out.println(user);
        System.out.println(reference.get());
    }

    static class User {
        private String userName;
        private int age;

        public User(String userName, int age) {
            this.userName = userName;
            this.age = age;
        }

        @Override
        public String toString() {
            return "User{" +
                    "userName='" + userName + '\'' +
                    ", age=" + age +
                    '}';
        }
    }
}

输出结果:

User{userName='a', age=1}
User{userName='b', age=2}

4)字段类型的原子操作类

如果需要更新对象的某个字段,并在多线程的情况下,能够保证线程安全,atomic 同样也提供了相应的原子操作类:

AtomicIntegeFieldUpdater:原子更新整型字段类;
AtomicLongFieldUpdater:原子更新长整型字段类;
AtomicStampedReference:原子更新引用类型,这种更新方式会带有版本号。解决CAS的ABA问题。

用法:

  1. 通过 AtomicIntegerFieldUpdater 的静态方法 newUpdater 来创建一个更新器,并且需要设置想要更新的类和属性;
  2. 更新类的属性必须使用 public volatile 进行修饰;
public class AtomicDemo {

    private static AtomicIntegerFieldUpdater updater = AtomicIntegerFieldUpdater.newUpdater(User.class,"age");
    public static void main(String[] args) {
        User user = new User("a", 1);
        int oldValue = updater.getAndAdd(user, 5);
        System.out.println(oldValue);
        System.out.println(updater.get(user));
    }

    static class User {
        private String userName;
        public volatile int age;

        public User(String userName, int age) {
            this.userName = userName;
            this.age = age;
        }

        @Override
        public String toString() {
            return "User{" +
                    "userName='" + userName + '\'' +
                    ", age=" + age +
                    '}';
        }
    }
}

8.6.总结

CAS 即比较和替换,可以高效的解决原子性问题。

CAS 原子操作原理:使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替换当前变量的值。

Java 中的 CAS:atomic 包下原子操作类,如 AtomicInteger 常用于修饰共享变量来保证原子性。

九、AQS分析

AbstractQueuedSynchronizer是Java并发包java.util.concurrent的核心基础组件,是实现Lock的基础。

AQS实现了对同步状态的管理,以及对阻塞线程进行排队、等待通知等,本文将从原理角度深入理解AQS的实现原理。

AQS维护了一个volatile int state(代表共享资源)和一个FIFO(先进先出)线程等待队列(多线程争取资源被阻塞时会进入此队列,此队列为双向链表结构)

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

image-20200527143459836

​ 不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

9.1.运行原理

  1. 线程1来获取锁,此时没有竞争,直接获取到锁。AQS队列为空。
  2. 线程2来获取锁,因为线程1占用锁,线程2需要做两件事:
    1. 线程2构造成Node到AQS的同步队列中排队。此时初始化同步队列。
    2. 线程2阻塞,等待被唤醒之后再去抢锁。
  3. 线程3来获取锁,锁被占用,同样做两件事:排队并阻塞。此时的同步队列结构:

img

  1. 线程1执行完同步代码之后释放锁,唤醒head的后继节点(线程2),线程2获取锁,并把线程2对应的Node置为head。
  2. 线程2执行完同步代码之后释放锁,唤醒head的后继节点(线程3),线程3获取锁,并把线程3对应的Node置为head。
  3. 线程3执行完同步代码之后释放锁,同步队列中head之后没有节点了,将head置为null即可。

9.2.总结

AQS结构:锁状态state、当前只有锁的线程exclusiveOwnerThread以及双向链表实现的同步队列。

AQS使用模板方法设计模式,子类必须重写AQS获取锁tryAcquire()和释放锁tryRelease()的方法,一般是对stateexclusiveOwnerThread的操作。

获取锁acquire()过程:

  • 子类调用tryAcquire()尝试获取锁,如果获取锁成功,完成。
  • 如果获取锁失败,当前线程会封装成Node节点插入同步队列中,并且将当前线程park()阻塞,等待被唤醒之后再抢锁。

释放锁release()过程:当前线程调用子类的tryRelease()方法释放锁,释放锁成功后,会unpark(thread)唤醒head的后继节点,让其再去抢锁。

十、ReentrantLock使用

ReentrantLock是Lock中用到最多的,与synchronized具有相同的功能和内存语义,本文将从源码角度深入分析AQS是如何实现ReentrantLock的。

注:本文是在默认理解AQS原理基础上分析ReentrantLock的,建议读者先读懂上一篇AQS原理。

10.1.Synchronized和lock的区别

10.1.1.原始构成

  • Synchronized是关键字,属于JVM层面。moitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象只有在同步代码块或方法中才能调wait/notity等方法) monitorexit
  • lock是具体类(java.util.concurrent.locks.Lock)是api层面的锁

10.1.2.使用方法

  • synchronized不需要手动释放,当synchronized代码执行完后系统会自动让线程释放对锁的占用
  • lock则需要手动释放若没有主动释放锁,就有可能导致出现死锁现象,需要lock()和unlock()方法配合try/finally语句块完成

10.1.3.等待是否可中断

  • synchronized不可中断,除非抛出异常或者正常运行完成
  • lock可中断:
    • 设置超时方法trylock(long timeout,TimeUnit unit)
    • lockInterruptibly()放代码块中,调用interrupt()方法可中断

10.1.4.加锁是否公平

  • synchronized非公平锁
  • lock两者都可以,默认公平锁,构造方法可以传入boolean值,true为公平锁,false为非公平锁

10.1.5.锁绑定多个条件Condition

  • synchronized没有
  • lock用来实现分组唤醒的线程们,可以精确唤醒,而不是像synchronize要么随机唤醒一个线程要么唤醒全部线程

10.2.锁类型

  • 公平锁/非公平锁:
    • 公平锁是指多个线程按照申请锁的顺序来获取锁。
    • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

  • 可重入锁:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。
对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

synchronized void setA() throws Exception{
    Thread.sleep(1000);
    setB();
}
 
synchronized void setB() throws Exception{
    Thread.sleep(1000);
}


上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。
  • 独享锁/共享锁:
    • 独享锁是指该锁一次只能被一个线程所持有。
    • 共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
对于Synchronized而言,当然是独享锁。

10.3.ReentrantLock使用

用法:

publicclass ReentrantLockDemo {
    privatestatic ReentrantLock reentrantLock = new ReentrantLock();
    
    public void createOrder() {
        reentrantLock.lock();// 获取锁
        try {
            // 同步代码
        } finally {
            reentrantLock.unlock();// 释放锁
        }
    }
}

需要注意两点:

  1. synchronized同步块执行完成或者遇到异常是锁会自动释放,而lock必须调用unlock()方法释放锁。
  2. 为了保证在获取到锁之后,最终能够被释放,在finally块中释放锁。

使用举例:

publicclass ReentrantLockTest {
    publicint inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final ReentrantLockTest test = new ReentrantLockTest();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 1000; j++)
                        test.increase();
                };
            }.start();
        }

        while (Thread.activeCount() > 1)
            // 保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

目的是得到test.inc=10000,但是因为线程安全问题,最终的结果总是小于10000。

使用synchronized解决办法是,用synchronized修饰increase()方法。同样可以使用重入锁解决,代码如下:

publicclass ReentrantLockTest {
    private ReentrantLock reentrantLock = new ReentrantLock();
    publicint inc = 0;

    public void increase() {
        reentrantLock.lock();// 加锁
        inc++;
        reentrantLock.unlock();// 解锁
    }

    public static void main(String[] args) {
        final ReentrantLockTest test = new ReentrantLockTest();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 1000; j++)
                        test.increase();
                };
            }.start();
        }

        while (Thread.activeCount() > 1)
            // 保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

10.3.类结构

publicclass ReentrantLock implements Lock, java.io.Serializable {
    privatefinal Sync sync;
    abstractstaticclass Sync extends AbstractQueuedSynchronizer {}
    staticfinalclass FairSync extends Sync {}
    staticfinalclass NonfairSync extends Sync {}
}

ReentrantLock用内部类Sync来管理锁,所以真正的获取锁和释放锁是由Sync的实现类来控制的。

Sync有两个实现,分别为NonfairSync(非公平锁)和FairSync(公平锁),以FairSync为例来讲解ReentrantLock,之后会专门分析公平锁和非公平锁。

10.4.获取锁

ReentrantLock分为公平锁和非公平锁,本文以公平锁为例讲解,下一篇将详细介绍公平锁与非公平锁。本文的源码讲解方式依然是在代码中适当位置加入注释。

/**
 * 获取锁reentrantLock.lock()-->ReentrantLock.lock()
 */
public void lock() {
    sync.lock();
}

/**
 * ReentrantLock.lock()-->ReentrantLock.FairSync.lock()
 */
final void lock() {
    acquire(1);
}

/**
 * ReentrantLock.FairSync.lock()-->AbstractQueuedSynchronizer.acquire(int)
 * 很熟悉了吧,上一篇讲的AQS获取锁的方法
 * 1.当前线程通过tryAcquire()方法抢锁
 * 2.线程抢到锁,tryAcquire()返回true,完成。
 * 3.线程没有抢到锁,将当前线程封装成node加入同步队列,并将当前线程挂起,等待被唤醒之后再抢锁。
 */
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

/**
 * ReentrantLock.FairSync.tryAcquire(int)
 * 实现了AQS的抢锁方法,抢锁成功返回true
 * 获取锁成功的两种情况:
 * 1.没有线程占用锁,且AQS队列中没有其他线程等锁,且CAS修改state成功。
 * 2.锁已经被当前线程持有,直接重入。
 */
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();// AQS的state (FairSync extends Sync extends AQS)
    if (c == 0) {// state==0表示当前没有线程占用锁
        if (!hasQueuedPredecessors() && // AQS同步队列中没有其他线程等锁的话,当前线程可以去抢锁,此方法下文有详解
            compareAndSetState(0, acquires)) {// CAS修改state,修改成功表示获取到了锁
            setExclusiveOwnerThread(current);// 抢锁成功将AQS.exclusiveOwnerThread置为当前线程
            returntrue;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        /*
         * AQS.exclusiveOwnerThread是当前线程,表示锁已经被当前线程持有,这里是锁重入
         * 重入一次将AQS.state加1
         */
        int nextc = c + acquires;
        if (nextc < 0)
            thrownew Error("Maximum lock count exceeded");
        setState(nextc);
        returntrue;
    }
    return false;
}

/**
 * AbstractQueuedSynchronizer.hasQueuedPredecessors()
 * 判断AQS同步队列中是否还有其他线程在等锁
 * 返回true表示当前线程不能抢锁,需要到同步队列中排队;返回false表示当前线程可以去抢锁
 * 三种情况:
 * 1.队列为空不需要排队, head==tail,直接返回false
 * 2.head后继节点的线程是当前线程,就算排队也轮到当前线程去抢锁了,返回false
 * 3.其他情况都返回true,不允许抢锁
 */
public final boolean hasQueuedPredecessors() {
    Node t = tail;
    Node h = head;
    Node s;
    return h != t && // head==tail时队列是空的,直接返回false
        ((s = h.next) == null || s.thread != Thread.currentThread());// head后继节点的线程是当前线程,返回false
}
  • 最终获取到锁的标志就是sync.state>0sync.exclusiveOwnerThread==当前线程
  • 判断锁的状态也是通过sync.state的值和sync.exclusiveOwnerThread来判断。

10.5.释放锁

/**
 * 释放锁reentrantLock.unlock()-->ReentrantLock.unlock()
 */
public void unlock() {
    sync.release(1);
}

/**
 * ReentrantLock.unlock()-->AbstractQueuedSynchronizer.release(int)
 * 同样是上一篇AQS中的释放锁方法
 * 释放锁成功之后,唤醒head的后继节点next,next节点被唤醒后再去抢锁。
 */
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        returntrue;
    }
    returnfalse;
}

/**
 * AbstractQueuedSynchronizer.release(int)-->ReentrantLock.Sync.tryRelease(int)
 * 释放重入锁。只有锁彻底释放,其他线程可以来竞争锁才返回true
 * 锁可以重入,state记录锁的重入次数,所以state可以大于1
 * 每执行一次tryRelease()将state减1,直到state==0,表示当前线程彻底把锁释放
 */
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        thrownew IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

10.6.如何实现重入

  1. 线程T获取到锁,AQS.state=1,AQS.exclusiveOwnerThread置为线程T。
  2. 线程T没释放锁之前再次调用lock()加锁,判断AQS.exclusiveOwnerThread==线程T,就可以直接执行不会阻塞,此时AQS.state加1。
  3. 此时线程T再次调用lock()加锁,继续重入,AQS.state再加1,此时state==2。
  4. 线程T执行完部分同步代码,调用unlock()解锁,AQS.state减1,此时state==1,线程T还持有该锁,其他线程还无法来竞争锁。
  5. 线程T执行完所有同步代码,调用unlock()解锁,AQS.state减1,此时state==0,线程将锁释放,允许其他线程来竞争锁。

state用于记录线程状态:state==0,没有线程占用该锁;state==1,一个线程持有该锁;state==n,一个线程持有该锁且重入了n次。

img

10.7.总结:

重入锁实现同步过程:

  1. 线程1调用lock()加锁,判断state=0,所以直接获取到锁,设置state=1 exclusiveOwnerThread=线程1。
  2. 线程2调用lock()加锁,判断state=1 exclusiveOwnerThread=线程1,锁已经被线程1持有,线程2被封装成节点Node加入同步队列中排队等锁。此时线程1执行同步代码,线程2阻塞等锁。
  3. 线程1调用unlock()解锁,判断exclusiveOwnerThread=线程1,可以解锁。设置state减1,exclusiveOwnerThread=null。state变为0时,唤醒AQS同步队列中head的后继节点,这里是线程2。
  4. 线程2被唤醒,再次去抢锁,成功之后执行同步代码。

线程最终获取到锁的标志就是AQS.state>0AQS.exclusiveOwnerThread==当前线程

Lock和AQS很好的隔离了使用者和实现者所需关注的领域。

  • Lock是面向使用者,定义了与使用者交互的接口,隐藏了实现细节;
  • AQS是面向Lock的实现者,实现了同步状态的管理,线程的排队,等待和唤醒等底层操作。

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://alone95.cn/archives/并发编程