java 并发编程:线程同步控制

2020-05-03 0 By admin

为了保证在多线程情况下数据访问的正确性,通常需要使用同步机制。
java 语言从 JDK1.0 版本开始就提供了同步锁,并且从 JDK1.5 开始提供了可重入锁、读写锁和原子操作等同步控制方式。

一、为什么要使用同步控制

当多个线程同时对某一个数据区或内存位置进行操作时,如果不施加任何措施,很可能造成数据操作混乱。
如同数据库中的 脏读、不可重复读和幻读等情况。

二、基本概念

2.1、数据竞争

当只有一个线程访问数据时,数据竞争基本不会存在;只有多线程同时访问数据时,才会发生数据竞争。
数据竞争问题是有至少两个同时执行的线程访问同一个内存位置并且至少有一个线程尝试写入数据而引起的问题。
为了避免数据竞争,通常需要在程序中加入同步机制,以保证数据访问的正确性。有些同步机制(如:锁)可以保证数据在某一时间内只有一个线程访问,
有些同步机制(如:软件事务性内存)可以让数据由多个线程操作,虽然多线程同时访问,但是会保证最早提交的数据有效,其他的数据操作要回滚。

2.2、临界区

某一段被多个线程共享的数据区域,线程必须对它进行互斥访问;线程中访问共享数据的那段代码被称为临界区(Critical Section)。线程进入临界区需要遵循一定的原则。

  1. 多个线程可以同时请求进入临界区,但同一时刻只允许一个线程进入。
  2. 当临界区被一个线程拥有时,其他线程需要等待,不允许进入该临界区。
  3. 临界区内的操作应该在有限的时间内完成,以便给其他线程运行的机会。
  4. 一个线程执行完临界区后,操作系统随机选取一个线程进入,其他未被选取的线程继续等待。

为了帮助程序员实现临界区,Java 提供了同步机制。当一个线程试图访问临界区时,同步机制会判断当前是否有其他线程正在使用临界区。

2.3、监视器

在 Java 语言中,监视器(Monitor)具有如下特征:

  1. 一个监视器是只有一个私有属性的类。
  2. 每个监视器类的对象实例都有一个相关联的锁。这个锁将对对象所有的方法加锁。方法调用开始时,自动获取对象的锁;方法执行完成后解锁。
  3. Java 中每个对象都有一个隐式的锁。

2.4、阻塞和非阻塞

当线程请求某一种资源时,如果线程的请求得不到响应,线程可以采用多种方式来决定接下来要采取的动作。
1、线程可以采用一直尝试的方式,在每次请求资源得不到得不到满足的情况下,下一次依然继续请求,直到请求获得满足。采用这种方式的线程处于一种非阻塞状态,如果资源被占用的时间过长,这种方式必然会导致CPU资源的浪费。
2、另外一种方式:线程并不是一直等待,而是被阻塞;这样CPU的资源可以让出来执行一些其他的操作。线程被阻塞表示线程可能被CPU挂起,等待某一时间后,再去尝试获取资源。

2.5、线程安全和线程不安全

在程序设计过程中,一般要先保证程序的正确性,其次才是提高程序的性能。
在传统的串行执行的程序中,程序往往有一个固定的执行次序,对于数据的访问操作也是有顺序的。
然而,在多线程程序中,必须保证数据被多个线程操作是安全的。

一个对象是否是线程安全的?
当多线程同时访问某个类时,不管线程之间如果交替执行,总能够得到正确的执行结果,则称这个类是线程安全的;否则是线程不安全的。

要编写线程安全的代码,需要特别注意哪些共享的(Shared)和可变的(Mutable)数据或状态的操作。共享意味着变量可以被对个线程所访问,可变意味着变量的值在其生命周期内会发生变化。
线程安全的代码需要采用同步机制来控制对于共享的或者可变的变量的访问,特别是多个线程中至少存在一个写操作的情况下。

Java 工具集合中提供的类有些是线程安全的(如:HashTable),有的则不是(如:HashMap)。一般在线程安全的类中已经封装了必要的同步控制机制,因此不必进一步采取同步控制措施。

三、锁

在程序的设计语言中,锁提供了一种数据安全访问的方式,锁一般分为加锁和解锁两个操作。对共享数据操作前,要先进行加锁;操作完成后,再进行解锁。
加锁以后的临界区只能被持有锁的线程占有,其他线程不能进入这段临界区,只能等待。
Java 语言提供同步锁、可重入锁和读写锁等同步机制,用于确保数据访问的正确性。

3.1、同步锁

在 JAVA 语言中,从 JDK1.0 开始就支持同步锁的使用了。它可以采用两形式:同步方法和同步代码块。
不论哪种方式,都需要使用 synchronized 关键字,但两者表现形式不同。

同步方法
采用 synchronized 作为方法的修饰词,将方法整体限定在同步控制区域内;同一时刻只能有一个线程对其进行访问。

同步块
同步块是使用 synchronized 修饰的一块代码,它不像同步方法那样使整个方法都是被同步控制,而是针对某一块代码进行同步控制。
同步块需要明确地指出监视器对象,通常加载 synchronized 后的小括内,使用比较多的情况是使用当前对象 this 作为监视器对象。

比较
同步块比同步方法可以实现更细粒度的同步控制,但同步方法的使用更加简便,不用考虑同步对象等因素。但是,有时整个方法加上 synchronized 块,程序性能并不好,这是因为函数内部可能需要同步的只有小部分共享数据而已。
需要注意的是,这两种方法都是使用 JVM 内置的监视器。

3.2、可重入锁

可重入锁是一种无阻塞的同步机制,它在 java.util.concurrent.locks 包下;定义的形式如下:
public class ReentrantLock extends Object implements Lock ,Serializable
可重入锁是互斥锁,它和同步锁具有基本相同的行为和语义,但是比同步锁功能更强大。如获取锁时的公平性设置、测试锁 trylock、测试锁是否正在被持有、锁的获取顺序等。

3.3、读写锁

读写锁从 JDK1.5 版本开始引入的一种锁机制,它维护一对相互关联的锁:读锁和写锁。在没有线程持有写锁的情况下,读锁可以由多个线程同时持有;写锁是排他锁,只能有一个线程持有。
读写锁允许多个线程同时读,只允许一个线程同时写。
读写锁 ReentrantReadWriteLock 类定义的一般形式:
public class ReentrantReadWriteLock extends Object implements ReadWriteLock,Serializable

3.4、邮戳锁

邮戳锁是 JDK1.8 版本后引入的一种锁机制,与 ReentrantReadWriteLock 类似,该锁可以用于控制读写访问。邮戳锁的定义形式:
public class StampedLock extends Object implements Serializable
从邮戳锁的定义可以看出,它是从类 Object 直接继承,与 ReentrantReadWriteLock 类似,它实现了 Serializable 接口。由于邮戳锁支持多种锁模式,所以这个类没有直接实现接口 Lock 和接口 ReadWriteLock。

3.5、死锁和活锁

在使用锁的时候,要注意避免死锁和活锁的问题,两者都会引起线程等待,降低程序的执行效率。
4.1、死锁
死锁是指两个或者多个线程在执行过程中,因竞争资源而相互等待的现象。处于死锁状态的线程无法继续运行,只有死锁解除才能继续。

4.2、活锁
活锁指程序在执行过程中,由于某些条件发送,会导致程序一直处于等待状态。与死锁类似,任务的处理一直处于等待状态,得不到解决,无法继续进行下去;与死锁不同的是,活锁有可能解开,但死锁不行。

四、volatile 变量

当多个线程对变量进行操作时,实际上每个线程都拥有自己的本地存储,在本地存储中有该变量的私有拷贝,变量的操作结果先放入本地存储,然后再复制回主存储区域。
如果一旦遇到多线程访问某一个类的域变量的情况,我们就使用锁进行同步控制,有时带来的开销可能比较大。因此,java 语言提供了一种稍弱的同步机制,即以 volatile 关键词修饰变量,它提供了对于实例域并发访问的功能。

五、原子操作

一个操作是原子的,表示该操作要么全部做,要么全部不做。
在同步控制操作中,原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换。有了原子操作后,一般不需要对程序加锁,通过原子操作可以实现程序的同步。
在 java.util.concurrent.atomic 包中提供了 AtomicBoolean、AtomicInteger、AtomicLong、AtomicIntegerArray和AtomicReference等原子类。这些原子类为单一变量提供了一种无锁的、线程安全的访问方式,每一个类提供了对于相应类型的变量进行原子更新的方法。

5.1、基本类型的原子类

基本类型的原子类包括:AtomicBoolean、AtomicInteger、AtomicLong等。

5.2、一般引用类型的原子类

一般引用类型的原子类:AtomicReference。

5.3、ABA 问题

原子操作相对于无锁、无阻塞的操作有一定的优势,但是它存在ABA问题。
CAS 操作一般通过比较某一个对象引用的当前值和期望值,如果当前值和期望值相等,则将其替换为新值。
CAS 操作的核心是看某一个值是否已经改变,也就是说,要保证在对某一变量操作时该变量没有被其他线程改变过,只要没有改变,就可以更新。
如果期间某一个线程对该值进行了改变,然后又恢复了原值,则这种情况就是ABA问题。

5.4、扩展的原子引用类

AtomicMarkableReference
AtomicMarkableReference 类是一个线程安全的类,该类封装了一个对象的引用 reference 和一个布尔值 mark,可以原子性地对这两个值进行更新。
AtomicStampedReference
AtomicStampedReference 类维护了一个对象的引用 reference 和一个整数值 stamp,这两个值可以原子地进行更新。

5.5、原子操作数组类

定义一个数组的原子操作:
AtomicInteger [] aiArray = new AtomicInteger[100];
JDK 中也提供了相关的类,可以使用原子操作数组类 AtomicIntegerArray 类来定义原子数组:
AtomicIntegerArray aia = new AtomicIntegerArray(100);