JUC学习之Lock同步锁

夙愿已清 提交于 2020-02-29 11:25:32

一、简介

引出Lock同步锁之前,先了解一下synchronized同步的一些缺陷:

如果一段代码被synchronized锁住,那么当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁。如果某个时刻获得锁的线程发生阻塞现象,那么这把锁会被它一直持有,而其他线程永远无法获取锁,正常来说,不能让其他线程永远在那里等待。

  • 使用Lock锁的话,提供了一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断);
  • 使用Lock锁的话,通过tryLock()方法可以尝试获取锁,这就知道线程有没有成功获取到锁;

基于上面Lock的两大优势,我们今天总结一下Lock同步锁相关的知识。

Lock是一个接口,源码如下:

public interface Lock {
    //获取锁,如果锁已被其他线程获取,则进行等待。
    void lock();
    //当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态
    void lockInterruptibly() throws InterruptedException;
    //尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false
    boolean tryLock();
    //指定时间内尝试获取锁,在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    //释放锁
    void unlock();
    //创建Condition
    Condition newCondition();
}

主要的方法有:lock()获取锁、tryLock()尝试获取锁、unlock()手动释放锁,主要使用Lock一定要手动释放锁,一般放在finally块中保证锁得到释放;

二、常用API使用

(1)、如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

下面是JDK官网推荐的写法:

Lock l = ...;
 l.lock();
 try {
   // access the resource protected by this lock
 } finally {
   l.unlock();
 }

(2)、尝试获取锁tryLock()

Lock lock = ...;
if(lock.tryLock()) {
     try{
         //处理业务
     }catch(Exception ex){
         
     }finally{
         //手动释放
         lock.unlock();   
     } 
} else {
    //未成功获取锁,则处理其他业务逻辑
}

下面我们介绍一下java.util.concurrent.locks包中常用的类和接口。

三、ReentrantLock可重入锁

ReentrantLock是可重入锁,也是经常使用到的同步锁之一。ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。

类图如下:

public class ReentrantLock implements Lock, java.io.Serializable {
    //...
}

 下面通过一个简单的示例说明ReentrantLock的使用方法:

我们模拟三个窗口卖30张火车票,如果使用synchronized同步的话,相信小伙伴们都会了,这里就不介绍了,这里使用ReentrantLock可重入锁实现。

/**
 * 模拟三个售票员卖出30张票
 */
public class T01_SaleTicketDemo01 {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        //窗口1
        new Thread(ticket::sale, "A").start();
        //窗口2
        new Thread(ticket::sale, "B").start();
        //窗口3
        new Thread(ticket::sale, "C").start();
    }
}

/**
 * 共享资源类
 */
class Ticket {
    private int number = 10;
    //声明可重入锁
    private Lock lock = new ReentrantLock();

    public void sale() {
        //获取锁
        lock.lock();
        try {
            while (true) {
                if (number > 0) {
                    TimeUnit.MILLISECONDS.sleep(10);
                    System.out.println(Thread.currentThread().getName() + "卖出了:" + (number--) + ",剩下:" + number);
                } else {
                    break;
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //必须手动释放锁,且放在finally块中
            lock.unlock();
        }
    }
}

运行结果:

A卖出了:10,剩下:9
A卖出了:9,剩下:8
B卖出了:8,剩下:7
C卖出了:7,剩下:6
A卖出了:6,剩下:5
A卖出了:5,剩下:4
A卖出了:4,剩下:3
A卖出了:3,剩下:2
A卖出了:2,剩下:1
A卖出了:1,剩下:0

可见,三个线程只要有一个线程获取了锁,其他线程就只能等待去操作共享资源。

四、ReadWriteLock读写锁

ReadWriteLock也是一个接口,源码如下:

public interface ReadWriteLock {
    /**
     * 获取读锁
     */
    Lock readLock();

    /**
     * 获取写锁,属于排他锁
     */
    Lock writeLock();
}

上面两个方法一个用来获取读锁,一个用来获取写锁,将读和写的锁分开,正常来说,读操作应该允许多个线程同时读,当一个线程写的时候,其他线程不能写也不能读。这就提高了读的效率。

常见的实现类就是ReentrantReadWriteLock:

可见,ReentrantReadWriteLock里面分了两把锁:readerLock和writerLock。下面通过例子来介绍一下ReentrantReadWriteLock具体用法:

先看下面的代码:

/**
 * ReentrantReadWriteLock读写锁
 * <p>
 * 读操作共享,写操作独占
 * 即读读可共享、写读写写要独占
 */
public class T11_ReentrantReadWriteLock {
    public static void main(String[] args) {
        //操作共享资源
        SharedData sharedData = new SharedData();

        //五个线程写
        for (int i = 1; i <= 5; i++) {
            final int num = i;
            new Thread(() -> {
                sharedData.put(String.valueOf(num), String.valueOf(num));
            }, "A" + num).start();
        }

        //五个线程读
        for (int i = 1; i <= 5; i++) {
            final int num = i;
            new Thread(() -> {
                sharedData.get(String.valueOf(num));
            }, "A" + num).start();
        }
    }
}

class SharedData {
    private volatile Map<String, Object> map = new HashMap<>();

    /**
     * 模拟写操作
     */
    public void put(String key, Object value) {
        System.out.println(Thread.currentThread().getName() + "写入开始....");
        try {
            TimeUnit.MILLISECONDS.sleep(400);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        map.put(key, value);
        System.out.println(Thread.currentThread().getName() + "写入完成....value = " + String.valueOf(value));
    }

    /**
     * 模拟读操作
     */
    public Object get(String key) {
        System.out.println(Thread.currentThread().getName() + "读取开始....");
        try {
            TimeUnit.MILLISECONDS.sleep(400);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Object result = map.get(key);
        System.out.println(Thread.currentThread().getName() + "读取完成....result = " + result);
        return result;
    }
}

运行结果:

A1写入开始....
A2写入开始....
A3写入开始....
A4写入开始....
A5写入开始....
A1读取开始....
A2读取开始....
A3读取开始....
A4读取开始....
A5读取开始....
A1读取完成....result = null
A2读取完成....result = null
A3读取完成....result = null
A4读取完成....result = null
A3写入完成....value = 3
A2写入完成....value = 2
A1写入完成....value = 1
A5读取完成....result = 5
A5写入完成....value = 5
A4写入完成....value = 4

从上面的结果可以看出,比如A1开始写入数据,到A1写入成功期间,居然那么多线程对共享资源进行了操作, 这明显有问题。正确结果应该是A1开始写入数据紧接着就是A1写入成功...依次类推。下面我们使用ReentrantReadWriteLock改造一下:

/**
 * ReentrantReadWriteLock读写锁
 * <p>
 * 读操作共享,写操作独占
 * 即读读可共享、写读写写要独占
 */
public class T11_ReentrantReadWriteLock {
    public static void main(String[] args) {
        //操作共享资源
        SharedData sharedData = new SharedData();

        //五个线程写
        for (int i = 1; i <= 5; i++) {
            final int num = i;
            new Thread(() -> {
                sharedData.put(String.valueOf(num), String.valueOf(num));
            }, "A" + num).start();
        }

        //五个线程读
        for (int i = 1; i <= 5; i++) {
            final int num = i;
            new Thread(() -> {
                sharedData.get(String.valueOf(num));
            }, "A" + num).start();
        }
    }
}

class SharedData {
    private volatile Map<String, Object> map = new HashMap<>();
    /**
     * 引入读写锁
     */
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    /**
     * 模拟写操作
     */
    public void put(String key, Object value) {
        readWriteLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "写入开始....");
            try {
                TimeUnit.MILLISECONDS.sleep(400);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "写入完成....value = " + value);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }

    /**
     * 模拟读操作
     */
    public Object get(String key) {
        readWriteLock.readLock().lock();
        Object result = null;
        try {
            System.out.println(Thread.currentThread().getName() + "读取开始....");
            try {
                TimeUnit.MILLISECONDS.sleep(400);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            result = map.get(key);
            System.out.println(Thread.currentThread().getName() + "读取完成....result = " + result);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readWriteLock.readLock().unlock();
        }
        return result;
    }
}

运行结果:

A1写入开始....
A1写入完成....value = 1
A2写入开始....
A2写入完成....value = 2
A3写入开始....
A3写入完成....value = 3
A4写入开始....
A4写入完成....value = 4
A5写入开始....
A5写入完成....value = 5
A1读取开始....
A2读取开始....
A3读取开始....
A4读取开始....
A5读取开始....
A1读取完成....result = 1
A3读取完成....result = 3
A2读取完成....result = 2
A5读取完成....result = 5
A4读取完成....result = 4

由此可知,多个线程只能有一个线程进行写操作,其他线程不能加塞,而读操作则允许多个线程同时读,这样就大大提升了读操作的效率。不过要注意的是,如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

五、总结

这里我们总结一下Lock和synchronized的区别:

  • Lock是一个接口,而synchronized是Java中的关键字;
  • Lock在代码执行时出现异常时不会自动释放锁,必须手动释放,而synchronized抛出异常时会自动释放锁;
  • Lock如果没有及时释放锁,很可能产生死锁现象,而synchronized由于会自动释放锁,不会导致死锁现象发生;
  • Lock可以让等待锁的线程响应中断,而synchronized不能响应中断,只能一直等待;
  • Lock通过tryLock尝试获取锁可知是否成功获取锁,而synchronized则不行;
  • 如果对共享资源竞争不激烈情况下,两者的性能其实差不多,但是如果竞争激烈,此时Lock的性能要远远优于synchronized方式;
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!