Java并发基础-锁的使用及原理(可重入锁、读写锁、内置锁、信号量等)

本文目录:

1 基础

1.1 可重入锁

可重入锁表示的是,如果一个线程在未释放已获得锁的情况下再次对该对象加锁,将可以加锁成功。而且可以不断的加锁成功多次。但需要注意的是,每次加锁操作必须对应着一次释放锁的操作。
如以下示例是可以运行的(但完全没这么写的必要):

public synchronized void a() {
    ...

    synchronized (this) {
        synchronized (this) {
            ...
        }
    }

    ...
}

为什么需要可重入锁?先看以下示例(使用内置锁):

public class TestObject{
  public synchronized void a() {
      ...

      b();  

      ...
  }

  public synchronized void b() {
      ... 
  }

}

public static void main(String[] args) {
    TestObject obj = new TestObject();  
    obj.a();  
}

以上示例中,a方法调用b方法,两个方法都被内置锁锁定,如果不可重入,那么在调用b的时候当前线程就会等待锁的释放-而实际锁又被自己占用,因此死锁就出现了。而可重入锁就是为了解决这个问题而出现的。
那为什么a方法和b方法可能会需要同时加锁呢?这是因为外部对象可能会单独调用b方法而不去调用a方法!如果b没有进行加锁处理那么可能会导致并发问题。

注意:
实际上可重入锁如ReentrantLock在其内部有一个计数器用于保存当前线程对该锁的加锁次数;如果为0是表示当前线程没有获取到该锁。

1.2 读写锁

读写锁内部实际上包含有两个锁对象:一个负责对读操作加锁,一个负责对写操作加锁。读操作不是排他的,也就是说同一时刻可以有多个线程同时占用读锁;而写操作必须是排它的,如果写锁被某个线程占有,那么任何的线程不但获取不到写锁,也获取不到读锁。
使用读写锁能够有效的提高并发;就是因为排它锁不允许同时读,而读写锁允许。

2 内置锁synchronized

对象的内置锁,它有以下特性:

  • 可以使用在方法或者代码段上; 但不可以使用在构造方法上。
  • 它是可重入的;
  • 当synchronized用于同一个对象时,同一时刻只有一个线程能够进入它被synchronized包围的区域。

    举个例子,如果某类的A方法和B方法都使用了synchronized关键字,当线程1在调用A方法时,无论线程2想要调用A或者B方法,它都只能等待A的调用完成。这是因为进入synchronized包围区域后,表示的是这个对象的内置锁已经被这个线程获取到,其它要进入synchronized区域的线程都只能等待。

  • 每一个对象都有一个内置锁,对象可以是类实例化后的对象,也可以是类本身。

  • 当内置锁使用在静态方法上时,表示的是对获取的类本身的内置锁,而不是实例化后的内置锁。

使用示例:

class Test {
    private Object obj = new Object();

    /**
     * 使用在方法上
     */
    public synchronized void test1() {
        //使用在this上,也使用在方法上时是使用同一个对象的内置锁
        synchronized(this) {...}

        //使用在某个对象上,表示的是对这个对象的内置锁进行加锁
        synchronized (obj) {...}
    }


    //使用在静态方法上,
    public static synchronized static void test2() {
        //使用在Class上,表示的是对类的内置锁进行加锁,与使用在静态方法上加锁的是同一个对象
        synchronized (Test.class) {...}
    }

}

内置锁可以简化加锁操作,也能够避免在使用Lock的时候出现一些很常见的问题如死锁等。因此synchronized能够满足需求时可以考虑优先使用内置锁。
但某些复杂场景下可能内置锁无法满足需求,如处理流程是下面这样的:

image

获取A锁后再获取B锁,然后先释放A锁。这种场景使用内置锁就无法满足。必须使用显示锁(Lock)

3 显式锁Lock

显式锁可以提供比synchronized更加灵活的加锁功能。synchronized的所有使用场景显式锁都能够满足,而且还可以支持更多复杂的操作场景。

Java中的显式锁UML图如下所示: 
这里写图片描述

它主要包含了两个接口和两个实现:
- Lock: 最顶层的锁接口,提供加锁与释放锁的接口方法;
- ReentrantLock:Lock的一个可重入锁的实现类;
- ReadWriteLock: 读写锁接口,提供获取读锁对象与获取写锁对象两个接口方法;
- ReentrantReadWriteLock:可重入读写锁的实现类;

3.1 简单示例

显式锁主要的方法就是lock与unlock,先通过一个简单示例来演示锁的使用。

class Test {
    private Lock lock = new ReentrantLock();
    private Map<String, String> map = new HashMap<>();

    /**
     * 插入
     * 如果关键字已经存在则不进行插入,否则进行插入
     *
     * @param key
     * @param value
     */
    public void insert(String key, String value) {
        lock.lock();
        try {
            if (!map.containsKey(key)) {
                map.put(key, value);
            }
        } finally {
            lock.unlock();
        }
    }
}

注意此处如果不加锁,在多线程环境下是有导致出问题的。多个线程同时向map中put同一个Key对应的值,最终存储的值将可能是某个线程put进去的,也可能都不是。

注意:加锁后到释放锁前的所有操作都必须被try{}包围起来,并且必须在finally中释放锁。否则如果在释放锁前某个处理抛出异常,将不会进行锁的释放操作,这样的话其它线程就永远获取不到锁了

3.2 锁常用操作

a. Lock接口

分类 方法 说明
加锁 lock 加锁,当前线程阻塞直到获取到锁
lockInterruptibly 加锁,当前线程阻塞直到获取到锁或者是被中断;中断后将会抛出InterruptedException
tryLock() 尝试加锁,方法会立即返回不会阻塞当前线程; 如果加锁成功将返回True,否则返回False
tryLock(time, unit) 尝试加锁,如果在指定时间内未获取到锁则返回False,获取到了则返回True; 如果等待过程中被中断将抛出InterruptedException
释放锁 unlock 当前线程释放所获取的当前锁

b. ReadWriteLock接口

方法 说明
readLock 返回一个读锁对象
writeLock 返回一个写锁对象

3.3 读写锁使用示例

如下例,假设有Cache对象对数据按Key、Value进行缓存,写时互斥而读时可同时读,实现如下: 

public class Cache {
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private Map<String, Object> dataMap = new HashMap<>();
    private static Cache instance = null;

    /**
     * 构造方法设置成Private防止外部调用
     */
    private Cache() {}

    /**
     * 获取Cache的实例
     * @return
     */
    public synchronized static Cache getInstance() {
        if (null == instance) {
            instance = new Cache();
        }
        return instance;
    }

    /**
     * 根据Key读取缓存的值
     * 如果无值时将返回Null
     * @param key
     * @return
     */
    public Object read(String key) {
        readWriteLock.readLock().lock();
        try {
            Object obj = dataMap.get(key);
            return obj;
        } finally {
            readWriteLock.readLock().unlock();
        }
    }

    /**
     * 将Key、Value的值写入缓存
     * 在写的过程中不能让其它线程读取,否则可能导致读出来的数据是一个不可预料的值。
     * 
     * @param key
     * @param value
     */
    public void write(String key, Object value) {
        readWriteLock.writeLock().lock();
        try {
            dataMap.put(key, value);
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }

}

4 信号量Semaphore

信号量是一种轻量锁,它主要用于控制某些有限资源在多线程之间的分配使用。假设最多只允许向数据库建立5个连接,那么同时有5个线程可以使用连接,如果同时请求的线程数超过5个,那么其它未获取许可的线程就只能等待正在运行中的线程释放连接。
Semaphore提供acquire()方法来获取许可,使用release方法来释放许可,初始化的时候可以设置信号量的个数,也可以设置该信号量的公平性参数。

说明
- 公平性指的是对信号量的请求是否是FIFO(先入先出)的;如果设置成True,那么先调用acquire方法的线程将优先获取到信号量; 如果设置成False,那么将不会保证这个顺序,后提交的可能比在等待中的更加早的获取到信号量。默认情况下是设置成False的。
- acquire方法也可以一次性获取多个信号量;当信号量数不够时,将会阻塞直到有信号量被其它线程释放并且数目足够。注意以下场景:如果设置成公平的,当前可使用的信号量为2个,A线程先申请的信号量为3个,B线程后申请两个,那么A与B都将会等待,而不会因为能够满足B线程的需求而优先让B线程获取到足够的信号量。

4.1 信号量使用示例

下例模拟实现连接池。

public static class ConnectionPool {
    private Semaphore semaphore = new Semaphore(10);
    private List<Connection> connections = new ArrayList<>();
    private static ConnectionPool instance = null;

    /**
     * 构造方法设置成Private防止外部调用
     */
    private ConnectionPool() {}

    /**
     * 获取Cache的实例
     * @return
     */
    public synchronized static ConnectionPool getInstance() {
        if (null == instance) {
            instance = new ConnectionPool();

            //初始化连接池,假设一个字符串就是一个连接
            for (int i = 0; i < 10; i++) {
                instance.connections.add(new Connection(i));
            }
        }
        return instance;
    }

    /**
     * 从连接池中获取连接
     * 如果连接池中连接不够,则会阻塞当前线程,直接有其它线程释放连接或者当前线程被中断
     *
     * @return 返回所获取的连接,如果线程被中断,则返回空;
     */
    public Connection getConnection() {
        try {
            //获取一个信号量,如果没有则当前线程阻塞
            semaphore.acquire();

            //表示已经获取到了信号量,那么此时连接池中肯定有未使用的连接
            synchronized (this) {
                //从连接池中获取未使用连接,由于可能会有多个线程同时获取连接,因此此处必须要进行同步处理
                for (Connection conn: connections) {
                    if (!conn.isUsed) {
                        conn.setUsed(true);
                        return conn.clone();
                    }
                }

                return null;
            }
        } catch (InterruptedException e) {
            //当前线程被中断时
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 将连接归还到连接池中
     *
     * @param connection
     */
    public void release(Connection connection) {
        Assert.assertNotNull(connection);
        //归还连接时,必须先将所使用的连接设置成未使用后再调用信号量的release,否则如果先release再归还可能导致其它线程被唤醒后获取连接失败。
        synchronized (this) {
            for (Connection conn: connections) {
                if (conn.getId() == connection.getId()) {
                    conn.setUsed(false);

                    break;
                }
            }
        }

        //释放信号量 
        semaphore.release();
    }
}

4.2 方法清单

信号量的使用方法可以分成两类,一类是acquire,一类是release,清单如下:

分类 方法 说明
获取信号量 acquire 获取信号量,当前线程阻塞直到获取到信号量或被中断;中断后将抛出InterruptedException
acquireUninterruptibly 获取信号量,不会被中断;当前线程阻塞直到获取到信号量
tryAcquire() 尝试获取信号量,方法会立即返回不会阻塞当前线程; 如果获取成功将返回True,否则返回False;如果正好有一个信号量被释放,则会获取到该信号量,不管是否有线程已经在它之前在排队等待信号量,即使是在公平值设置成True的情况下。
tryAcquire(time, unit) 尝试获取信号量,如果在指定时间内未获取到锁则返回False,获取到了则返回True; 如果等待过程中被中断将抛出InterruptedException
释放信号量 release 当前线程释放所获取的信号量

其中,每个acquire与release方法还有一个带int参数的变种,表示的是获取或者释放指定个数的信号量。

猜你喜欢

转载自blog.csdn.net/icarusliu/article/details/79625874