02-Lock接口

一、Synchronized

1. Synchronized 关键字回顾

synchronized 是 Java 中的关键字,是一种同步锁。它修饰的对象有以下几种:

  • 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是 大括号{} 括起来的代码,作用的对象是调用这个代码块的对象;

  • 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;

    虽然可以使用 synchronized 来定义方法,但 synchronized 并不属于方法定义的一部分,因此,synchronized 关键字不能被继承。如果在父类中的某个方法使用了 synchronized 关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized 关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。

  • 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;

  • 修饰一个类,其作用的范围是 synchronized 后面括号括起来的部分,作用主的对象是这个类的所有对象。

2. Synchronized案例

使用 synchronized 完成售票案例

//第一步  创建资源类,定义属性和和操作方法
class Ticket {
    //票数
    private int number = 30;
    //操作方法:卖票
    public synchronized void sale() {
        //判断:是否有票
        if(number > 0) {
            System.out.println(Thread.currentThread().getName()+" : 卖出:"+(number--)+" 剩下:"+number);
        }
    }
}

public class SaleTicket {
    //第二步 创建多个线程,调用资源类的操作方法
    public static void main(String[] args) {
        //创建Ticket对象
        Ticket ticket = new Ticket();
        //创建三个线程
        new Thread(new Runnable() {
            @Override
            public void run() {
                //调用卖票方法
                for (int i = 0; i < 40; i++) {
                    ticket.sale();
                }
            }
        },"AA").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                //调用卖票方法
                for (int i = 0; i < 40; i++) {
                    ticket.sale();
                }
            }
        },"BB").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                //调用卖票方法
                for (int i = 0; i < 40; i++) {
                    ticket.sale();
                }
            }
        },"CC").start();
    }
}
  • 注意:Synchronized 不是公平锁,所以执行这个程序有可能会出现某个线程把票卖完,而其它线程没有执行机会的情况

如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:

  • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
  • 线程执行发生异常,此时 JVM 会让线程自动释放锁

那么如果这个获取锁的线程由于要等待 IO 或者其他原因(比如调用 sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过 Lock 就可以办到。

二、Lock接口

Lock 锁实现提供了比使用同步方法和语句可以获得的更广泛的锁操作。它们允许更灵活的结构,可能具有非常不同的属性,并且可能支持多个关联的条件对象。Lock 提供了比 synchronized 更多的功能。

Lock 与的 Synchronized 区别:

  • Lock 不是 Java 语言内置的,synchronized 是 Java 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问;
  • Lock 和 synchronized 有一点非常大的不同,采用 synchronized 不需要用户去手动释放锁,当 synchronized 方法或者 synchronized 代码块执行完之后,系统会自动让线程释放对锁的占用;而 Lock 则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

1. Lock 接口

接口代码路径:java.util.concurrent.locks.Lock

public interface Lock {

    /**
     * Acquires the lock.
     */
    void lock();

    /**
     * Acquires the lock unless the current thread is
     * {@linkplain Thread#interrupt interrupted}.
     */
    void lockInterruptibly() throws InterruptedException;

    /**
     * Acquires the lock only if it is free at the time of invocation.
     */
    boolean tryLock();

    /**
     * Acquires the lock if it is free within the given waiting time and the
     * current thread has not been {@linkplain Thread#interrupt interrupted}.
     */
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    /**
     * Releases the lock.
     */
    void unlock();

    /**
     * Returns a new {@link Condition} instance that is bound to this
     */
    Condition newCondition();
}

下面来逐个讲述 Lock 接口中每个方法的使用

1.1 lock() 方法

lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用 Lock 必须在 try{}catch{} 块中进行,并且将释放锁的操作放在finally 块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用 Lock来进行同步的话,是以下面这种形式去使用的:

lock.lock();
try {
//处理任务
} catch (Exception e) {

} finally {
    lock.unlock(); //释放锁
}

测试案例:买票

三个售票员,同时卖30张票,不能超卖。

使用 ReentrantLock

// 定义资源类、属性、方法
class LTickets {

    //票数量
    private int num = 30;

    public int getNum() {
        return num;
    }

    // 创建可重入锁
    private ReentrantLock lock = new ReentrantLock(true);

    //卖票方法
    public void sale() {
        //上锁
        lock.lock();
        try {
            // 判断是否还有票
            if (num > 0) {
                System.out.println(Thread.currentThread().getName() + " 卖出1张票,还剩:" + --num +" 张票");
            }
        } finally {
            // 解锁
            lock.unlock();
        }
    }

}

public class ReentrantLockDemo {
    //第二步 创建多个线程,调用资源类的操作方法
    //创建三个线程
    public static void main(String[] args) {
        LTickets lTickets = new LTickets();

        new Thread(() -> {
            while (lTickets.getNum() > 0) {
                lTickets.sale();
            }
        }, "售票员1").start();

        new Thread(() -> {
            while (lTickets.getNum() > 0) {
                lTickets.sale();
            }
        }, "售票员2").start();

        new Thread(() -> {
            while (lTickets.getNum() > 0) {
                lTickets.sale();
            }
        }, "售票员3").start();

    }
}

运行结果

售票员1 卖出1张票,还剩:29 张票
售票员2 卖出1张票,还剩:28 张票
售票员3 卖出1张票,还剩:27 张票
......
售票员3 卖出1张票,还剩:3 张票
售票员1 卖出1张票,还剩:2 张票
售票员2 卖出1张票,还剩:1 张票
售票员3 卖出1张票,还剩:0 张票

1.2 newCondition() 方法

关键字 synchronizedwait() / notify() 这两个方法一起使用可以实现等待/通知模式, Lock 锁的 newContition() 方法返回 Condition 对象,Condition 类也可以实现等待/通知模式。

notify() 通知时,JVM 会随机唤醒某个等待的线程, 使用 Condition 类可以进行选择性通知, Condition 比较常用的两个方法:

  • await() 会使当前线程等待,同时会释放锁,当其他线程调用 signal() 时,线程会重新获得锁并继续执行。
  • signal() 用于唤醒一个等待的线程。

注意:在调用 Condition 的 await()/signal() 方法前,也需要线程持有相关的 Lock 锁,调用 await() 后线程会释放这个锁,在 singal() 调用后会从当前Condition 对象的等待队列中,唤醒 一个线程,唤醒的线程尝试获得锁, 一旦获得锁成功就继续执行。

测试案例

当前有一个变量 number,初始值是0,创建四个线程同时修改次变量,线程AA、线程CC对变量做++操作, 线程BB、线程DD对变量做–操作。每个线程都执行10次

class ShareNum {
    private int number = 0;

    private ReentrantLock lock = new ReentrantLock(true);
    private Condition condition = lock.newCondition();

    // ++操作
    public void increase() {
        // 上锁
        lock.lock();
        try {
            while (number != 0) {
                condition.await();
            }
            // 执行 ++ 操作
            number++;
            System.out.println(Thread.currentThread().getName() + "执行了 ++ 操作,当前number值为:" + number);
            // 唤醒其它线程
            condition.signalAll();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 解锁
            lock.unlock();
        }
    }

    // -- 操作
    public void decrease() {
        // 上锁
        lock.lock();
        try {
            while (number != 1) {
                condition.await();
            }
            // 执行 -- 操作
            number--;
            System.out.println(Thread.currentThread().getName() + "执行了 -- 操作,当前number值为:" + number);
            // 唤醒其它线程
            condition.signalAll();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 解锁
            lock.unlock();
        }
    }
}

public class ConditionDemo {
    public static void main(String[] args) {
        ShareNum shareNum = new ShareNum();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareNum.increase();
            }
        }, "AA").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareNum.decrease();
            }
        }, "BB").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareNum.increase();
            }
        }, "CC").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareNum.decrease();
            }
        }, "DD").start();
    }
}

三、ReentrantLock

重入锁简单来说一个线程可以重复获取锁资源,虽然ReentrantLock不像synchronized关键字一样支持隐式的重入锁,但是在调用lock方法时,它会判断当前尝试获取锁的线程,是否等于已经拥有锁的线程,如果成立则不会被阻塞。

还有ReentrantLock在创建的时候,可以通构造方法指定创建公平锁还是非公平锁。

1. 代码示例

public class ReentrantLockTest {
    private ArrayList<Integer> arrayList = new ArrayList<>();

    public static void main(String[] args) throws InterruptedException {
        final ReentrantLockTest test = new ReentrantLockTest();
        // 锁放在这里,保证两个线程使用的是同一把锁
        Lock lock = new ReentrantLock(); //注意这个地方

        new Thread(() -> {
            test.insert(Thread.currentThread(), lock);
        }).start();

        new Thread(() -> {
            test.insert(Thread.currentThread(), lock);
        }).start();

        Thread.sleep(1000);
    }

    public void insert(Thread thread, Lock lock) {
        lock.lock();
        try {
            System.out.println(thread.getName()+"得到了锁");
            for(int i=0;i<5;i++) {
                arrayList.add(i);
            }
        } catch (Exception e) {
            // TODO: handle exception
        }finally {
            System.out.println(thread.getName()+"释放了锁");
            lock.unlock();
        }
    }

    /**
     * 计算总数
     */
    private void count() {
        int size = arrayList.size();
        System.out.println("总数为:" + size);
    }
}

运行结果

Thread-0得到了锁
Thread-0释放了锁
Thread-1得到了锁
Thread-1释放了锁

四、ReadWriteLock 读写锁

1. 乐观锁和悲观锁

悲观锁( synchronized 关键字和 Lock 的实现类都是悲观锁)

  • 什么是悲观锁?认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改,这样别人想拿数据就被挡住,直到悲观锁被释放,悲观锁中的共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

  • 但是在效率方面,处理加锁的机制会产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,如果已经锁定了一个线程A,其他线程就必须等待该线程A处理完才可以处理

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确(写操作包括增删改)、显式的锁定之后再操作同步资源

    synchronized关键字和Lock的实现类都是悲观锁,数据库中的行锁,表锁,读锁(共享锁),写锁(排他锁),均为悲观锁,表锁会发生死锁,读锁和写锁都会发生死锁现象。

  • 悲观锁不支持并发

悲观锁

乐观锁

  • 概念:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
  • 乐观锁在Java中通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的,适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅度提升

乐观锁

乐观锁一般有两种实现方式

  • 采用版本号机制
  • CAS算法实现
//悲观锁的调用方式
public synchronized void m1() {
    //加锁后的业务逻辑
}

//保证多个线程使用的是同一个lock对象的前提下
ReetrantLock lock = new ReentrantLock();
public void m2(){
    lock.lock();
    try {
        //操作同步资源
    } finally {
        lock.unlock();
    }
}

//乐观锁的调用方式
//保证多个线程使用的是同一个AtomicInteger
private  AtomicInteger atomicIntege=new AtomicInteger();
atomicIntege.incrementAndGet();

2. 读写锁ReadWriteLock

2.1 读写锁概述

我们开发中应该能够遇到这样的一种情况,对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是当一个写线程在写这些共享资源时,就不允许其他线程进行访问

读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。我们将读操作相关的锁,称为读锁,因为可以共享读,我们也称为“共享锁”,将写操作相关的锁,称为写锁、排他锁、独占锁每次可以多个线程的读者进行读访问,但是一次只能由一个写者线程进行写操作,即写操作是独占式的。

读写锁适合于对数据结构的读次数比写次数多得多的情况。因为读模式锁定时可以共享, 以写模式锁住时意味着独占, 所以读写锁又叫共享-独占锁

2.2 ReadWriteLock读写锁

针对上面这种场景,Java的并发包下提供了读写锁 ReadWriteLock(接口) | ReentrantReadWriteLock(实现类)。

ReadWriteLock 维护了一对相关的锁,一个用于只读操作, 另一个用于写入操作。只要没有 writer读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

public interface ReadWriteLock {
	// 读锁
    Lock readLock();
	// 写锁
    Lock writeLock();
}

ReadWriteLock 读取操作通常不会改变共享资源,但执行写入操作时,必须独占方式来获取锁

  • 对于读取操作占多数的数据结构。 ReadWriteLock 能提供比独占锁更高的并发性
  • 而对于只读的数据结构,其中包含的不变性可以完全不需要考虑加锁操作。
  • 读/写锁使用后都需要分别关闭,跟Lock最后也需要手动关闭是一样一样的。
  • ReadWriteLock是比lock锁更加细粒度的控制

2.3 ReentrantReadWriteLock实现类

ReentrantReadWriteLock实现了ReadWriteLock接口,下面是它的源码

public class ReentrantReadWriteLock implements ReadWriteLock,
java.io.Serializable {
    /** 读锁 */
    private final ReentrantReadWriteLock.ReadLock readerLock;
    /** 写锁 */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    final Sync sync;

    /** 使用默认(非公平)的排序属性创建一个新的
		ReentrantReadWriteLock */
    public ReentrantReadWriteLock() {
        this(false);
    }
    /** 使用给定的公平策略创建一个新的 ReentrantReadWriteLock */
    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }
    /** 返回用于写入操作的锁 */
    public ReentrantReadWriteLock.WriteLock writeLock() { return
        writerLock; }

    /** 返回用于读取操作的锁 */
    public ReentrantReadWriteLock.ReadLock readLock() { return
        readerLock; }
    abstract static class Sync extends AbstractQueuedSynchronizer {}
    static final class NonfairSync extends Sync {}
    static final class FairSync extends Sync {}
    public static class ReadLock implements Lock, java.io.Serializable {}
    public static class WriteLock implements Lock, java.io.Serializable {}
}

2.4 读写锁注意点

当读写锁是写加锁状态时, 在这个锁被解锁之前, 所有试图对这个锁加锁的线程都会被阻塞

当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁, 它必须直到所有的线程释放锁.

线程想要进入读锁的前提条件:

  • 不存在其他线程的写锁
  • 没有写请求, 或者有写请求,但调用线程和持有锁的线程是同一个(可重入锁)

线程进入写锁的前提条件:

  • 没有读者线程正在访问
  • 没有其他写者线程正在访问

通常, 当读写锁处于读模式锁住状态时,如果有另外线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求,这样可以避免读模式锁长期占用,而等待的写模式锁请求长期阻塞

2.5 读写锁的特点

  • 公平选择性:
    • 非公平模式(默认)
      • 当以非公平初始化时,读锁和写锁的获取的顺序是不确定的。非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量。
    • 公平模式
      • 当以公平模式初始化时,线程将会以队列的顺序获取锁。当当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁;或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。
      • 当有写线程持有写锁或者有等待的写线程时,一个尝试获取公平的读锁(非重入)的线程就会阻塞。这个线程直到等待时间最长的写锁获得锁后并释放掉锁后才能获取到读锁。
  • 可重入
    • 读锁和写锁都支持线程重进入。但是写锁可以获得读锁,读锁不能获得写锁。因为读锁是共享的,写锁是独占式的。
  • 锁降级
    • 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
  • 支持中断锁的获取
    • 在读锁和写锁的获取过程中支持中断
  • 监控
    • 提供一些辅助方法,例如hasQueuedThreads方法查询是否有线程正在等待获取读锁或写锁、isWriteLocked方法查询写锁是否被任何线程持有等等

2.6 案例演示

场景:我们通过一个缓存的小案例来,在没有使用锁的情况下,实现存储和读取的功能,并通过在多个线程的并发下。:使用 ReentrantReadWriteLock 对一个 hashmap 集合进行读和写的并发操作

volatile关键字:表示数据会不断发生变化,多个线程可见性,禁止指令重排序

没有锁的情况

public class ReentrantReadWriteLockDemo {
    //创建 map 集合
    private volatile Map<String, Object> map = new HashMap<>();

    //放数据
    public void put(String key, Object value) {
        System.out.println(Thread.currentThread().getName() + "正在写数据" + key);
        //放数据
        map.put(key, value);
        System.out.println(Thread.currentThread().getName() + "写完了" + key);
    }

    //取数据
    public Object get(String key) {
        Object result = null;
        System.out.println(Thread.currentThread().getName() + "正在取数据" + key);
        //        TimeUnit.MICROSECONDS.sleep(300);
        result = map.get(key);
        System.out.println(Thread.currentThread().getName() + "取完数据了" + key);
        return result;
    }

    public static void main(String[] args) {

        ReentrantReadWriteLockDemo demo = new ReentrantReadWriteLockDemo();

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程放数据
                demo.put(String.valueOf(number), number);
            }, String.valueOf(i)).start();
        }

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程取数据
                demo.get(String.valueOf(number));
            }, String.valueOf(i)).start();
        }
    }
}

执行结果:

1正在取数据1
2正在取数据2
4正在写数据4
3正在写数据3
3正在取数据3
2取完数据了2
4正在取数据4
1取完数据了1
4取完数据了4
2正在写数据2
3取完数据了3
4写完了4
1正在写数据1
3写完了3
2写完了2
5正在取数据5
1写完了1
5取完数据了5
5正在写数据5
5写完了5

可以看出在一个写线程写数据的时候,有其他线程进入,这显然是不行的。

使用ReadWriteLock读/写锁解决缓存并发问题

public class ReentrantReadWriteLockDemo {
    //创建 map 集合
    private volatile Map<String, Object> map = new HashMap<>();

    //创建读写锁对象
    private ReadWriteLock rwLock = new ReentrantReadWriteLock();

    //放数据
    public void put(String key, Object value) {
        //添加写锁
        rwLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "正在写数据" + key);
            //暂停一会
            TimeUnit.MICROSECONDS.sleep(300);
            //放数据
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "写完了" + key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //释放写锁
            rwLock.writeLock().unlock();
        }
    }

    //取数据
    public Object get(String key) {
        //添加读锁
        rwLock.readLock().lock();
        Object result = null;
        try {
            System.out.println(Thread.currentThread().getName() + "正在取数据" + key);
            //暂停一会
            TimeUnit.MICROSECONDS.sleep(300);
            result = map.get(key);
            System.out.println(Thread.currentThread().getName() + "取完数据了" + key);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //释放读锁
            rwLock.readLock().unlock();
        }
        return result;
    }

    public static void main(String[] args) {

        ReentrantReadWriteLockDemo demo = new ReentrantReadWriteLockDemo();

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程放数据
                demo.put(String.valueOf(number), number);
            }, String.valueOf(i)).start();
        }

        for (int i = 1; i <= 5; i++) {
            final int number = i;
            new Thread(() -> {//5个线程取数据
                demo.get(String.valueOf(number));
            }, String.valueOf(i)).start();
        }
    }
}

执行结果:

1正在写数据1
1写完了1
2正在写数据2
2写完了2
3正在写数据3
3写完了3
4正在写数据4
4写完了4
5正在写数据5
5写完了5
1正在取数据1
2正在取数据2
4正在取数据4
3正在取数据3
5正在取数据5
2取完数据了2
1取完数据了1
5取完数据了5
3取完数据了3
4取完数据了4

从结果可以看出,写操作是唯一独占的,多个线程不能同时写,必须等一个线程写完了另外一个线程才能进去,而读的时候是共享的,多个线程可以一起读数据。

2.7 总结(重点)

与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,即不能同时存在读写线程,总结起来为:读读不互斥,读写互斥,写写互斥。而一般的传统独占锁是:读读互斥,读写互斥,写写互斥,而场景中往往读远远大于写,读写锁就是为了这种优化而创建出来的一种机制。注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。因此需要根据实际情况选择使用。

  • ReentrantReadWriteLock和Synchonized、ReentrantLock比较起来有哪些区别呢?或者有哪些优势呢?
  • Synchonized、ReentrantLock是属于传统独占锁,读、写操作每次都只能是一个人访问,效率比较低。
  • 而ReentrantReadWriteLock读操作可以共享,提升性能,允许多人一起读操作,而写操作还是每次一个人访问。
  • 当然ReentrantReadWriteLock优势是有,但是也存在一些缺陷,容易造成锁饥饿,因为如果是读线程先拿到锁的话,并且后续有很多读线程,但只有一个写线程,很有可能这个写线程拿不到锁,它可能要等到所有读线程读完才能进入,就可能会造成一种一直读,没有写的现象。

3. 锁降级

3.1 概述

概念:

锁降级的意思就是写锁降级为读锁。而读锁是不可以升级为写锁的。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程,最后释放读锁的过程。

编程模型:

获取写锁—>获取读锁—>释放写锁—>释放读锁

代码演示

public class ReadWriteLockDemo2 {

    public static void main(String[] args) {
        ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
        // 获取读锁
        ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
        // 获取写锁
        ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
        
        //1、获取到写锁
        writeLock.lock();
        System.out.println("获取到了写锁");
        
        //2、获取到读锁
        readLock.lock();
        System.out.println("继续获取到读锁");
        //3、释放写锁
        writeLock.unlock();
	   //4、 释放读锁
        readLock.unlock();
    }
}

结果:

获取到了写锁
继续获取到读锁

也许大家觉得看不出什么,但是如果将获取读锁那一行代码调到获取写锁上方去,可能结果就完全不一样拉。

public class ReadWriteLockDemo2 {

    public static void main(String[] args) {
        ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
        // 获取读锁
        ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
        // 获取写锁
        ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();

        //1、 获取到读锁
        readLock.lock();
        System.out.println("获取到了读锁");

        writeLock.lock();
        System.out.println("继续获取到写锁");

        writeLock.unlock();
        readLock.unlock();
    }
}

结果:执行到读锁就停止了,即读锁不能升级为写锁。

获取到了读锁

原因:

因为在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的前提条件是,当前没有读者线程,也没有其他写者线程,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。

但是在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

3.2 使用场景

对于数据比较敏感, 需要在对数据修改以后, 获取到修改后的值, 并进行接下来的其它操作.

我们来看个比较实在的案例:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CacheDemo {
    /**
     * 缓存器,这里假设需要存储1000左右个缓存对象,按照默认的负载因子0.75,则容量=750,大概估计每一个节点链表长度为5个
     * 那么数组长度大概为:150,又有雨设置map大小一般为2的指数,则最近的数字为:128
     */
    private Map<String, Object> map = new HashMap<>(128);
    private ReadWriteLock rwl = new ReentrantReadWriteLock();
    private Lock writeLock=rwl.writeLock();
    private Lock readLock=rwl.readLock();

    public Object get(String id) {
        Object value = null;
        readLock.lock();//首先开启读锁,从缓存中去取
        try {
            //如果缓存中没有  释放读锁,上写锁
            if (map.get(id) == null) { 
                readLock.unlock();
                writeLock.lock();
                try {
                    //防止多写线程重复查询赋值
                    if (value == null) {
                        //此时可以去数据库中查找,这里简单的模拟一下
                        value = "redis-value";  
                    }
                    //加读锁降级写锁,不明白的可以查看上面锁降级的原理与保持读取数据原子性的讲解
                    readLock.lock(); 
                } finally {
                    //释放写锁
                    writeLock.unlock(); 
                }
            }
        } finally {
            //最后释放读锁
            readLock.unlock(); 
        }
        return value;
    }
}

如果不使用锁降级功能,如先释放写锁,然后获得读锁,在这个获取读锁的过程中,可能会有其他线程竞争到写锁 或者是更新数据 则获得的数据是其他线程更新的数据,可能会造成数据的污染,即产生脏读的问题。

3.4 锁降级的必要性

锁降级中读锁的获取是否必要呢?

答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 george_95@126.com