Java多线程编程(3)- 线程同步

       线程同步机制是一套用于协调线程间的数据访问及活动的机制,该机制用于保障线程安全以及实现这些线程执行的共同目标。Java平台提供的线程同步机制包括锁、volatile关键字、final关键字、static关键字以及一些相关的API。

1 锁基础

1.1 锁概述

       锁是对共享数据进行保护的许可证。一个线程在访问共享数据之前必须申请相应的锁(许可证),线程的这个动作被称为锁的获得(Acquire)。一个线程获得某个锁(持有许可证),就称该线程为相应锁的持有线程。一个锁一次只能被一个线程持有。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放(Release)相应的锁。锁的持有线程在其获得锁之后和释放锁之前这段时间内执行的代码被称为临界区(Critical Section)。因此,共享数据只允许在临界区进行访问,临界区一次只能一个线程执行。

       从锁的定义可以看出,锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有,这种锁也被称为排他锁或互斥锁(Mutex)。

       Java虚拟机中有两类锁:内部锁(Intrinsic Lock)和显式锁(Explicit Lock)。内部锁是通过synchronized关键字实现的,显式锁是通过java.util.concurrent.locks.Lock接口的实现类(如java.util.concurrent.locks.ReentrantLock)实现的。

1.2 锁用途

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

  • 锁通过互斥保障原子性。持有锁的线程执行临界区代码期间,没有其他线程能够访问相应的共享数据,从而使得临界区代码所执行的操作自然地具有不可分割的特性,即具备了原子性。互斥的本质是将多个线程对共享数据的访问由原来的并发改为了串行操作。因此,并发虽然是多线程编程的目标,但这种并发往往是并发中带有串行的并发。
  • 可见性的保障则是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作来实现的。Java平台中,锁的获得隐含着刷新处理器缓存这个动作,从而使得读线程在获得锁之后在执行临界区代码前可以将写线程对共享数据的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,从而使得写线程对共享数据的更新能够写入到该线程执行处理器的高速缓存中,因而对读线程可同步,即保障可见性。
  • 锁能够保障有序性。由于锁可见性的保障,写线程在临界区对任何一个共享变量的更新都对读线程可见,并且由于临界区的操作具有原子性,因此写线程对上述共享变量的更新会同时对读线程可见,即在读线程看来这些变量就像是在同一刻被更新的,其无法区分写线程实际是以什么样的顺序更新变量的,意味着读线程可以认为写线程是依照代码顺序更新这些变量的,即有序性得以保障。需要注意的是,尽管锁能够保障有序性,但并不意味着临界区的内存操作不能够被重排序。临界区内的任意两个操作依然可以在临界区之内被重排序(但不会重拍到临界区外)

锁保障安全三性需要满足两个条件:

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

1.3 锁相关概念

可重入性

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

void function1(){
    acquireLock(lock);//申请锁
    function2();
    releaseLock(lock);//释放锁
}

void function2(){
    acquireLock(lock);//申请同一个锁
    dosomething();
    releaseLock(lock);//释放同一个锁
}

       函数function1使用了锁lock,该锁引导的临界区代码调用了另一个函数function2,而function2也使用了lock,那么这里就遇到一个问题:function1的执行线程持有锁lock的时候调用了function2,而function2又去申请锁lock,而lock此时正被当前线程持有未释放,那么function2是否能申请lock成功呢?可重入性就是阐述了这样一个问题。

锁的争用与调度

       锁可以被看做多线程程序访问共享数据时所需持有的一种排他性资源。因此,关于资源争用、调度等概念对锁也是适用的。Java平台中锁的调度策略包括公平调度和非公平调度。相应的锁被称为公平锁和非公平锁。内部锁属于非公平锁,而显式锁既支持公平锁又支持非公平锁。

锁的粒度

       一个锁实例可以保护一个或多个共享数据。一个锁锁保护的共享数据的数量多少就称为该锁的粒度(Granularity),大则称为粗粒度,否则就称为细粒度。粒度粗细时相对的,通常粒度过细会增加锁调度的开销。

锁的开销

       锁的开销包括锁的申请和释放锁产生的开销,以及锁可能导致的上下文切换的开销。

锁泄露

       锁泄露是指一个线程获得某个锁之后,由于程序错误或缺陷导致该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。

2 内部锁(Synchronized关键字)

       Java平台中的任何一个对象都有唯一一个与之关联的锁,这种锁被称为监视器(Monitor)或者内部锁(Intrinsic Lock)。内部锁是一种排他锁,能保障线程安全三性。内部锁通过synchronized关键字实现。

       Synchronized关键字可以用来修饰方法和代码块(即{}包裹的代码)。synchronized修饰的方法被称为同步方法。同步方法的整个方法就是一个临界区。synchronized修饰的静态方法称为同步静态方法,同步静态方法相当于以当前类对象为引导锁的同步块。synchronized修饰的实例方法就被称为同步实例方法,同步实例方法相当于以“this”为引导锁的同步块。

Synchronized关键字修饰的代码块被称为同步块,语法表示为:

synchronized(锁句柄){

//临界区

}

       锁句柄是一个对象的引用(或者是能够返回对象的表达式),比如,锁句柄可以填写this关键字(即当前对象),习惯上直接称锁句柄为锁。锁句柄对应的监视器被称为相应同步块的引导锁,相应的同步块称为该锁引导的同步块。

       作为句柄的变量通常采用private final修饰,因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竞态。内部锁的申请与释放的动作由Java虚拟机负责。因此,内部锁的使用不会导致锁泄露。

3 显式锁(Lock接口)

       显式锁是java.util.concurrent.locks.Lock接口的实例。该接口对显式锁进行了抽象,java.util.concurrent.locks.ReentrantLock是Lock接口的默认实现类。显式锁的使用方法:

  1. 创建Lock接口的实例,没有特别要求的话,通常是创建ReentrantLock的实例作为显式锁使用。
  2. 在访问共享数据前申请相应的显式锁,也就是直接调用Lock.lock();
  3. 在临界区中访问共享数据。Lock.lock()与Lock.unlock()调用之间的代码区为临界区。
  4. 共享数据访问结束后释放锁。通过调用Lock.unlock()函数释放锁。为了避免泄露,我们通常将这个调用放入finally块中执行。
private final Lock lock = new ReentrantLock();
lock.lock();
try{
    dosomething();
}
finally{
    lock.unlock();
}

       ReentrantLock既支持非公平锁也支持公平锁。可以通过构造函数指定是否公平锁。公平锁保障锁调度的公平性,但增加了线程上下文切换的开销。总的来说,公平锁的开销比使用非公平锁的要大,因此显式锁默认使用的是非公平调度策略。

显式锁和内部锁的比较

  • ReentrantLock不是用来代替内部锁的,各自适用场景不同。
  • 内部锁基于代码块,不够灵活。显式锁基于对象,可以充分发挥面向对象的灵活性,支撑在一个方法内申请,却在另一个方法里释放。
  • 内部锁比较简单,不会出现锁泄露。显式锁必须在finally中释放锁
  • 内部锁持有的线程如果一直不释放锁,其它同步在该锁上的所有线程就会一直被暂停导致任务无法进展。显式锁可以使用tryLock来避免。
  • ReentrantLock的性能比较好。

public class UseTryLock {
    //tryLock()在锁空闲的时候才获取该锁
    //锁在获取时还没有被任何线程持有,如果获取的时候被线程持有,则不会去试图获取锁
    private final Lock lock1 = new ReentrantLock();
    private final Lock lock2 = new ReentrantLock();
    public void method1(){
        if(lock1.tryLock()){
            try{
                System.out.println(Thread.currentThread().getName() + "获取锁lock1等待锁lock2...");
                try {
                //获取到lock1,休眠10毫秒   
                TimeUnit.MICROSECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if(lock2.tryLock()){
                    try{
                        System.out.println(Thread.currentThread().getName() + "获取锁lock2...");
                    }finally{
                        lock2.unlock();
                    }
                }
            }finally{
                lock1.unlock();
            }
        }
    }
    public void method2(){
        if(lock2.tryLock()){
            try{
                System.out.println(Thread.currentThread().getName() + "获取锁lock2等待锁lock1...");
                if(lock1.tryLock()){
                    try{
                        System.out.println(Thread.currentThread().getName() + "获取锁lock1...");
                    }finally{
                        lock1.unlock();
                    }
                }
            }finally{
                lock2.unlock();
            }
        }
    }
    public static void main(String[] args){
        final UseTryLock tryLock = new UseTryLock();
        new Thread(tryLock::method1,"线程1").start();
        new Thread(tryLock::method2,"线程2").start();
    }
}
/*
 * 控制台输出
 * 线程1获取锁lock1等待锁lock2...
 * 线程2获取锁lock2等待锁lock1...
 * 线程1获取锁lock2..
 */
 分析:线程1获取锁lock1休眠10毫秒等待锁lock2... 
 在线程1休眠10毫秒间,线程2执行,线程2获取锁lock2等待锁lock1...
 然后lock1.tryLock()尝试获取lock1的锁,返回false,执行结束释放lock2

4 读写锁

       锁的排他性使得多个线程无法以线程安全的方式在同一时刻对共享变量进行读取(只读取而不更新),这不利于提高系统的并发性。对共享变量仅进行读取而没有更新的线程为只读线程,简称读线程。对共享变量进行更新(包括先读取后更新)的线程成为写线程。

       读写锁的功能是通过读锁和写锁实现的。读锁是共享的,一个读线程持有一个读锁,其他读线程也可以获得该读锁。写锁是排他的,即一个线程持有写锁的时候其他线程是无法获取相应的写锁或读锁的。写锁保障了写线程对共享变量的访问是独占的,而读锁实际上也只是在读线程之间共享的,任何一个线程持有一个读锁的时候,其他任何线程也无法获取相应的写锁。这样就保证了读线程在访问共享变量期间不被修改,并使多个读线程可以同时读取这些变量从而提高并发性。而写锁保证了写线程以独占的方式安全地更新共享变量。写线程对共享变量的更新对读线程是可见的。

获取条件排他性作用
读锁相应的写锁未被任何线程持有对读线程是共享的,对写线程是排他的允许多个读线程可以同时读取共享变量,并保证读线程读取共享变量期间没有其他任何线程能够更新这些共享变量。
写锁读写锁未被其他任何线程持有并且相应的读锁未被其他线程持有多写线程和读线程都是排他的写线程以独占的方式安全地访问共享变量

       java.util.concurrent.locks.ReadWriteLock接口时读写锁的抽象。其默认实现类是java.util.concurrent.locks.ReentrantReadWriteLock.

public class UseReadWriteLock {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Lock readLock = readWriteLock.readLock();
    private final Lock writeLock = readWriteLock.writeLock();
    public void reader(){
        readLock.lock();
        try{
            //读取共享数据
            System.out.println(Thread.currentThread().getName() + "进入...");
            TimeUnit.MILLISECONDS.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "退出...");
            TimeUnit.MILLISECONDS.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally{
            readLock.unlock();
        }
    }
    public void writter() {
        writeLock.lock();
        try{
            //读取共享数据
            System.out.println(Thread.currentThread().getName() + "进入...");
            TimeUnit.MILLISECONDS.sleep(3000);
            System.out.println(Thread.currentThread().getName() + "退出...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally{
            writeLock.unlock();
        }
    }
    public static void main(String[] args) {
        final UseReadWriteLock useReadWriteLock = new UseReadWriteLock();
        new Thread(useReadWriteLock::reader,"读线程1").start();
        //new Thread(useReadWriteLock::reader,"读线程2").start();
        new Thread(useReadWriteLock::writter,"写线程").start();
    }
    /**
     *只开启两个读线程,两个读线程可以同时进入,获取到读锁 
     * 读线程2进入...
     * 读线程1进入...
     * 读线程2退出...
     * 读线程1退出..
     * 分别开启一个读写线程,不能同时进行
     * 写线程进入...
     * 写线程提出..
     * 读线程1进入..
     * 读线程1退出..
     */

       读写锁内部实现比其他锁要复杂,不合适的适用可能会增加开销,其合适的场景是:

  • 只读操作比写操作要频繁得多
  • 读线程持有锁的时间比较长 

       注意,ReentrantReadWriteLock实现的读写锁是可重入的,其支持锁的降级,即一个线程持有读写锁的写锁的情况下还可以继续获得相应的读锁。但不支持锁的升级,即一个线程在持有读写锁的读锁的情况下,申请获得写锁。该线程如果要申请写锁,则需要先释放读锁,然后再申请相应的写锁。

5 volatile关键字

       volatile关键字被称为轻量锁,其作用与锁的作用相同的是保证可见性和有序性,不同的是原子性方面它仅保障写volatile变量操作的原子性(但并不代表对volatile变量的赋值操作具备原子性),但不具备锁的排他性。另外,volatile关键字的适用并不会引起上下文切换。

       volatile关键字的作用有:保障可见性、有序性和long/double型变量读写操作的原子性。对volatile变量的写操作,java虚拟机会在该操作之前插入一个释放屏障,并在该操作之后插入一个存储屏障。关于内存屏障,请读者查阅资料了解。

        volatile关键字并不是要取代锁,volatile和锁各有其适用场景。volatile更适合多个线程共享一个状态变量(或对象),锁更适合多个线程共享一组状态变量。

volatile应该场景主要有:

  1. 使用volatile变量作为状态标志。这种场景下,应用程序的某个状态由一个线程设置,其他线程会读取该状态并以该状态作为其计算的依据。此时,使用volatile变量作为同步机制的好处是一个线程能够通知另一个线程某类事件发生,而这些线程又无须因此而使用锁,避免锁的开销和问题。
  2. 使用volatile保障可见性。在该场景下,多个线程共享一个可变状态变量,其中一个线程更新了该变量后,其他线程在无须加锁的情况下也能够看到该更新。
  3. 使用volatile变量代替锁。volatile更适合多个线程共享一个状态变量(或对象),但通常比锁性能更好,代码简单。当状态变量不止一个时,比如多个共享的一组状态变量时,我们可以把这一组变量封装成一个对象,从而可以用一个volatile变量来引用该对象,保障了原子性和可见性,而无须适用锁来实现。
  4. 使用volatile实现简易版读写锁。在该场景中,读写锁是通过混合使用锁和volatile变量来实现的,其中锁用于保障共享变量写操作的原子性,volatile保障共享变量的可见性。该读写锁仅涉及一个共享变量并且允许一个线程读取允许一个线程读取这个共享变量时其他线程可以更新该变量,所以这种读写锁允许读线程读取到共享变量的非最新值。
public class SimpleRWlock{
    private volatile long count;
    public long value(){
        return count;
    } 
    public void inc(){
        synchronized(this){
            count++;
        }
    }
}

6 原子变量类

原子变量类(Atomics)是基于CAS实现的能够保障对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类。包含了基础类型、数组型、字段更新器、引用型等,具体可以参考sdk中以Atomic**的类。

注:CAS是一个原子的if-then-else的操作,只保障共享变量更新这个操作的原子性,并不保障可见性,其语义代码如下:

boolean CompareAndSwap(Variable V,Object A,Object B){
    if(A == V.get()){ //check,检查变量是否被其他线程修改过
        V.set(B);     //act,更新变量值   
        return true;  //更新成功
    }
    return false;     //变量值已被其他线程修改,更新失败
}

7 static和final关键字

       static关键字能够保证一个线程即使在未使用其他同步机制的情况下也总是可以读取到一个类的静态变量的初始值(而不是默认值)。对于引用型静态变量,static还确保了该变量引用的对象已经初始化完毕。但是,static的这种可见性和有序性保障仅在一个线程初次读取静态变量的时候才起作用。如果这个静态变量在相应类初始化完毕之后被其他线程更新过,那么一个线程要读取该变量的相对新值仍需要借助锁、volatile等同步机制。

       final关键字只能保障有序性,即保障一个对象对外可见的时候该对象的final字段必然是初始化完毕的。而非final字段没有这种保障,即这些线程读取该对象的非final字段时所读取的值仍然可能时相应字段的默认值。对于引用final字段,final关键字还进一步确保该字段所引用的对象已经初始化完毕,即这些线程读取该字段所引用的对象的各个字段时所读取到的值都是相应字段的初始值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值