JAVA并发编程(三)-锁

Scroll Down

JAVA并发编程(三)-锁

JAVA中锁的介绍

锁概述

线程安全问题都是多个线程并发访问共享变量,共享资源导致的,锁的作用就是讲共享数据的并发访问转换成串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后才能被其他线程继续访问.

一个线程获得某个锁,我们就称该线程为相应锁的持有线程,一个锁一次只能被一个线程持有,锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后必须要释放锁,因此共享数据只允许在**临界区(获得锁之后和释放锁之前的代码)**进行访问,临界区一次只能被一个线程访问并执行.

锁具有排他性,一个锁一次只能被一个线程持有,这种锁称为排他锁或者互斥锁.也有另外一种特殊的锁-读写锁.

JAVA平台的锁包括内部锁和显示锁,内部锁是通过synchronized关键字实现的,显示锁是通过java.util.concurrent.locks.Lock的实现类来实现的.

锁是通过互斥来保障原子性的,因为一个锁一次只能被一个线程持有,一个临界区代码一次只能被一个线程执行,所以临界区执行的代码具有不可分割的特性,即保障了原子性.

在JAVA中,锁的获得隐含着刷新处理器缓存(从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步)的动作,锁的释放隐含着冲刷处理器缓存(对共享变量所做的更新最终写入该处理器的高速缓存或者主内存中)动作,因此锁也可以保障可见性

由于锁的互斥性和可见性,同一个锁保护的数据一次只能被一个线程访问,因此线程在临界区中所读取的共享数据的相对新值也是最新值.

锁能够保障有序性,因为临界区内所有的写操作对读线程都可见,并且临界区内的操作具有原子性,因此读线程无法也没有必要区分写线程实际上是以什么顺序更新上述变量的,即可以认为临界区内的代码都是有序的,有序性得到保障.

虽然锁能保障有序性,但是为了性能考虑,临界区内的代码还是可以被重排序(但是不会重排序到临界区之外),由于临界区内的操作具有原子性,因此这种重排序对于其他线程来说没有影响.

锁保障可见性,原子性和有序性的条件是:

  • 线程在访问共享变量时用的同一个锁
  • 线程中的任意一个线程,即便是读取数据不更新数据也需要持有对应锁

可重入性

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

void methodA(){
//申请锁
acquiredLock(lock);
//业务代码
methodB();
releaseLock(lock);
//释放锁
}
void methodB(){
//申请锁
acquireLock(lock);
//业务代码
//释放锁
releaseLock(lock);
}

其中方法A使用了锁Lock,然后在临界区内又使用了方法B,方法B也使用了锁Lock,那么这时候能否申请成功就意味着锁是否可重入的.

锁的争用与调度

锁也和线程调度策略一样,有公平策略和非公平策略,内部锁就是非公平锁,显示锁可以支持公平锁又支持非公平锁

锁的粒度

一个锁的实例所保护的共享数据量的大小称为锁的粒度.锁的粒度过粗会导致申请锁的时候需要进行不必要的等待,锁的粒度过细会增加锁调度的开销.

锁的开销及其可能导致的问题

多个线程争用排他性资源时会导致上下文切换,锁作为一种排他性资源,一旦被争用就可能导致上下文切换,而没有被争用的锁不会导致上下文切换.

  • 锁泄露(Lock Leak):锁泄露是指一个线程获得某个锁之后,由于程序的错误致使锁一直无法被释放导致其他线程一直无法获得该锁的现象.锁泄露的问题可导致:可重入锁在争用程度比较低的情况下极有可能只有一个线程反复申请该锁,

内部锁:synchronized关键字

JAVA中内部锁是通过synchronized关键字来实现的,synchronized关键字可以用来修饰方法以及代码块.synchronized关键字修饰的方法叫同步方法,修饰的静态方法就叫同步静态方法,修饰的实例方法称为同步实例方法,同步方法的整个方法体就是一个临界区,修饰的代码块称为同步块.

synchronized(锁句柄){
//访问共享变量
}

synchronized关键字所引导的代码块就是临界区,锁句柄是一个对象的引用(或者能够返回对象的表达式),锁句柄可以填写为this关键字(表示当前对象),我们习惯上称锁句柄为锁,锁句柄对应的监视器就成为相应代码块的引导锁,我们相应的同步块为该锁引导的同步块.

作为锁句柄的变量通常都要采用final关键字修饰,因为如果锁句柄的变量值一旦改变,会导致执行同一个同步代码块的多个线程实际上使用不同的锁,这样就会导致竞态的发生.通常我们使用private final 修饰锁句柄的变量,例如:private final Object lock = new Object();

线程在执行临界区代码的时候必须持有该临界区的引导所,一个线程执行到同步块时必须先申请该同步块的引导锁,只有申请成功获得该锁的线程才能执行相应的临界区.一个线程执行完临界区代码后引导该临界区的锁就会被自动释放.这个过程中线程对锁的申请与释放都由java虚拟机来实施,这也是synchronized实现的锁称为内部锁的原因.并且内部锁的使用不会导致锁泄露,因为java编译器在将同步代码块编译为字节码的时候对临界区内可能抛出异常而又未捕获的异常进行代为处理,这样即使临界区内代码出现异常也不会妨碍锁的释放.

内部锁的调度

Java虚拟机会为每个内部锁分配一个入口集(Entry Set).用于记录等待获得相应内部锁的线程,当多个线程申请同一个锁的时候,申请失败的线程会被暂停并被存放到相应锁的入口集中等待再次申请锁的机会,入口集中的线程就被称为相应内部锁的等待线程.Java虚拟机对内部锁的调度仅支持非公平调度,所以等待线程占用处理器运行时还可能被其他活跃线程抢占该释放锁,因此被唤醒的线程不一定能成为该锁的持有线程.

显示锁:Lock接口

显示锁是从JDK1.5之后引入的排他锁,作为一种线程同步机制,他的作用与内部锁相同,但是提供了一些内部锁不具备的特性.java.util.concurrent.locks.ReentrantLock是Lock接口的默认实现类

					lock.lock();// 申请锁Lock
                    try {
                        //共享数据访问
                    } finally {
                        //在finally中解锁,以免锁泄露
                        lock.unlock();
                    }

显示锁的调度

ReentrantLock既支持公平锁也支持非公平锁,默认是非公平锁,如果需要使用公平锁可以使用

    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

公平锁保障调度的公平性往往是增加了线程的暂停和唤醒的可能性,即增加了上下文切换为代价的,公平锁适合锁被持有的实际相对长或者线程申请锁的平均时间间隔长的情形.总的来说公平锁的开销比使用非公平锁的开销大,因此显示锁默认使用的是非公平调度策略.

内部锁还是显示锁?

默认情况下使用内部锁,在多数线程持有一个锁的时间相对长或者线程申请锁的平均时间间隔相对长的情况下应该使用公平锁(显示锁)

读写锁

锁的排他性导致多个线程无法以线程安全的方式在同一个时刻对共享变量进行读取(只读取而不更新),这不利于提高系统的并发性.

对于同步在同一个锁之上的线程而言,仅对共享变量进行读取而没进行更新的线程定义为只读线程,对共享变量进行更新(包括先读取后更新)的线程称为写线程

读写锁是一种改进型的排它锁,读写锁允许多个线程同时进行读取(只读)共享变量,但是一次只允许一个线程对共享变量进行更新.任何线程在读取共享变量的时候,其他线程无法更新这些变量;一个线程在更新共享变量的时候,其他线程无法访问(读取)该变量

读写锁功能是通过读锁写锁实现的.读线程在访问共享变量时必须拥有读写锁中的读锁,读锁是可以同时被多个线程持有的,读锁是**共享(shared)**的,写线程在访问共享变量的时候必须持有相应读写锁的写锁,写锁是排他的,即一个线程持有写锁的时候其他线程无法获得相应锁的写锁或者读锁.写锁保障了写线程对共享变量的访问是独占的,而读锁实际上只在读线程中共享,任何一个线程获得读锁时,其他任何线程无法获得相应锁的写锁,这就保障了读线程在读取共享变量期间时没有其他线程能够对共享变量进行更新.读锁对于多线程来说起到保护其访问的变量在访问期间不被修改的作用,也能使多个线程可以同时读取这些变量从而提高了并发性;而写锁保障了写线程能够以独占的方式安全的更新共享变量,写线程对共享变量的更新是可见的.

获得条件排他性作用
读锁相应的写锁未被任何线程持有对读线程是共享的,对写线程是排他的允许多个读线程同时读取共享变量,并且保证读线程读取共享变量期间没有其他任何线程能更新这些共享变量
写锁该写锁未被其他任何线程持有并且相应的读锁未被其他任何线程持有对写线程和读线程都是排他的写线程能够以独占的方式访问共享变量
java.util.concurrent.locks.ReadWriteLock 是对读写锁的抽象,默认实现类是java.util.concurrent.locks.ReentrantReadWriteLock
 private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    public void operationWithLockDowngrade() {
        boolean readLockAcquired = false;
        writeLock.lock(); // 申请写锁
        try {
            // 对共享数据进行更新
            // ...
            // 当前线程在持有写锁的情况下申请读锁readLock
            readLock.lock();
            readLockAcquired = true;
        } finally {
            writeLock.unlock();// 释放写锁
        }

        if (readLockAcquired) {
            try {
                // 读取共享数据并据此执行其他操作
                // ...

            } finally {
                readLock.unlock();// 释放读锁
            }
        } else {
            // ...
        }
    }

读写锁适合在以下条件下使用:

  • 只读操作比更新操作频繁的多
  • 读线程持有锁的实际比较长

只有同时满足上面两个条件的时候,读写锁才是合适的选择,否则使用读写锁开销反而更大.

ReentrantReadWriteLock实现的读写锁是一个可重入锁,ReentrantReadWriteLock支持锁的降级,即一个线程持有写锁时可以继续获取读锁,但是ReentrantReadWriteLock并不支持锁的升级,如果获取了读锁再去申请写锁,就必须要先释放读锁,再申请相应的写锁.

锁的适用场景

当多个线程共享同一组数据时,如果其中有线程设计如下操作是,我们就可以考虑使用锁:

  • check-then-act操作:一个线程读取共享变量并在此基础上决定其下一个操作是什么
  • read-modify-write操作:一个线程读取共享数据并在此基础上更新改数据,如果是某些像自增操作 i++,这种简单的操作,可以使用原子变量类来实现线程安全
  • 多个线程对多个共享数据进行更新:共享数据之间存在联系性,比如一个主机的IP地址和端口,那么为了保障操作的原子性我们可以考虑使用锁.