Java并发编程基础篇(二)——各类锁的使用方法

各类锁的使用方法是Java并发编程使用层面的核心。本篇延续上篇的内容,重点讲述线程同步所需要的各种锁的使用方法,包括轻量级的volatile关键字、synchronized关键字、ReentrantLock、ReadWriteLock、StampedLock等。
在进入正式内容之前,先简单讲下Java的内存模型。以便于更好地带出volatile关键字的内容。

1、Java内存模型

JVM中,所有对象实例都存储在堆内存中,堆内存在线程之间共享,而局部变量、方法定义参数等存储在栈内存中,不会在线程之间共享。

java 简单锁 java 锁使用_读锁


Java内存模型(JMM)定义了线程与主内存之间的抽象关系,规定所有的共享变量都储存在主内存,每条线程有自己的工作内存,保存变量的主内存副本,所有对变量的操作都必须在工作内存进行。

JVM的内存模型描述了Java程序运行所需要的的不同类型数据的物理位置,而JMM内存模型抽象了一套不同线程之间的共享变量的可见关系。这是两个不同视角。

需要注意的是,正如方法区和永久代已经成为抽象概念一样,本地内存也是JMM抽象出的一个概念,并不真实存在,而是涵盖了缓存、写缓冲区、寄存器等。JMM内存模型如下:

java 简单锁 java 锁使用_java_02

2、volatile关键字

JMM内存模型可能会导致共享内存不可见问题,例如:主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。
解决方法之一就是使用volatile关键字进行轻量级的同步。当一个变量被声明为volatile时:
(1)线程修改共享变量时会回写到主内存;
(2)线程读取共享变量时总是获取主内存中的最新值而不是当前工作内存的值。
然而,volatile并不能保证操作的原子性,并不一定能够保证线程安全。
什么是操作的原子性?就是指一组命令要么都执行、要么都不执行。
例如,对于语句n = n + 1,看上去是一行语句,实际上对应了3条指令:

ILOAD
IADD
ISTORE

我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于如果线程1在执行ILOAD后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD后获取的值仍然是100,最终结果被两个线程的ISTORE写入后变成了101,而不是期待的102。
显然,n=n+1并不是原子性操作,也无法保证线程安全。为了保证线程安全,常见的方法包括:为代码块添加synchronized关键字、使用锁、使用原子操作类、使用线程安全的集合类等。

3、synchronized关键字

synchronized保证代码块在任意时刻最多只有一个线程能执行,进而保证了操作的原子性。使用synchronized有多种实现方式:

(1)在需要同步的代码块上加上synchronized关键字,锁上某个共享实例:

class Counter {
    private static final Object lock = new Object();
    private static int count = 0;

    public void add(int n) {
        synchronized(lock) {
            count += n;
        }
    }

    public void dec(int n) {
        synchronized(lock) {
            count -= n;
        }
    }
}

(2)在需要同步的类中锁上当前实例:

public class Counter {
    private static int count = 0;

    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    }

    public void dec(int n) {
        synchronized(this) {
            count -= n;
        }
    }
}

(3)在方法修饰符中加入sychronized关键字,效果同(2):

public class Counter {
    private static int count = 0;

    public synchronized void add(int n) {
        count += n;
    }

    public synchronized void dec(int n) {
        count -= n;
    }
}

值得注意的是,如果我们不能保证Counter类只有一个实例,那么使用(2)和(3)的方法往往是不推荐的:因为如果不同的线程使用了不同的Counter实例对象,将会持有各个Counter对象的不同的锁实例,互相不影响,导致同步失效。对于静态变量的修改一定要保证各个线程之间持有的是同一个锁实例,例如方法(1)中的lock对象,是用static修饰的,保证了全局唯一性,如果去掉这里的static,则同样也是线程不安全的。
JVM中,synchronized的实现是通过执行monitorenter和monitorexit指令实现的,线程进入方法块的时候,执行monitorenter指令,尝试获取对象的锁,如果能够获取到该锁,则锁的计数器值+1,退出synchronized块时执行指令monitorexit,锁的计数器值-1,减到0的时候,才会真正释放锁。
从这里也可以看出,synchronized是一个可重入锁,即如果一个线程获得了锁,它还可以再次获得自己已经拥有的锁。
在上面的例子中,如果我们规定:count小于0时,不允许线程减少count,那么可以通过在同步对象上加上wait()和notify()或notifyAll()方法实现,其中:
wait()方法可以使当前占用了同步对象的线程临时释放该同步对象,并等待;可以使用Object.wait(long timeout)的方法,设置超时时间;
notify()和notifyAll()唤醒正在等待的线程从this.wait()语句中立即返回,继续干活。

public class Counter {
    private int count = 0;

    public synchronized void add(int n) {
        count += n;
        // (1)增加count后,count不再小于等于0,现在可以唤醒还在(2)沉睡的线程了
        this.notifyAll(); 
    }

    public synchronized void dec(int n) {
        while (count <= 0) {
            // (2) 如果count小于等于0,就让减少线程睡眠
            this.wait();
        }
        count -= n;
    }
}

注意wait的判断是while循环而不是if判断,因为有可能有多个减少count的线程先后进入(2)并依次沉睡。如果使用if判断的话,被notifyAll()同时叫醒的线程们会一个接一个执行count -= n的语句。

4、ReentrantLock

ReentrantLock是JUC包提供的一个锁。仍延续上面的例子,如果换成使用ReentrantLock实现的话:

public class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    public void add(int n) {
        Lock.lock(); //获取锁
        try {
            count += n;
            condition.signalAll(); // 替代this.notifyAll()
        } finally {
            lock.unlock(); // 释放锁
        }
    }

    public synchronized void dec(int n) {
        lock.lock();
        try {
            while (count <= 0) {
                condition.await(); // 替代this.wait()
            }
            count -= n;
        } finally {
            lock.unlock();
        }
    }
}

可以看出,和synchronized不同,Lock对象需要显性地获取和释放,并且使用Condition对象实现wait和notify功能。
另一个不同点在于,Lock对象可以使用tryLock方法,在指定时间内没有获取到锁,就返回false,不再傻傻地等待;同样,Condition对象中的await()可以在等待指定时间后,如果还没有被其他线程通过signal()或signalAll()唤醒,可以自己醒来:

public class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    public void add(int n) {
        // 尝试获取锁,如果1秒之后还是获取不到,就进入else分支
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
	        try {
	            count += n;
	            condition.signalAll(); 
	        } finally {
	            lock.unlock(); 
	        }
        } else {
            System.out.println("增加线程获取锁失败");
        }
    }

    public synchronized void dec(int n) {
        // 尝试获取锁,如果1秒之后还是获取不到,就进入else分支
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
	        try {
	            while (count <= 0) {
	                // 尝试等待10秒,如果10秒内没有被其他线程唤醒也会自己到时候醒来
	                condition.await(10, TimeUnit.SECOND); 
	            }
	            count -= n;
	        } finally {
	            lock.unlock();
	        }
        } else {
            System.out.println("减少线程获取锁失败");
        }
    }
}

还有一个不同之处在于,ReentrantLock可以通过设置构造函数从默认的非公平锁变为公平锁。而synchronized锁一定是非公平锁。公平锁指的是锁的获取顺序应当符合请求时间的绝对顺序,而非公平锁下各个线程获取锁的先后顺序和请求时间的绝对顺序无关。非公平锁虽然会导致有的线程陷入饥饿,却也带来了更少的线程切换开销与更大的吞吐量。

5、ReentrantReadWriteLock

JUC包中的ReentrantReadWriteLock允许多个线程同时读取数据,但只要有一个线程在写,其他线程就必须等待。
可以调用实例方法readLock()和writeLock()分别获取读锁和写锁:

public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];
    private static volatile update = false;

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}

另外,ReentrantReadWriteLock支持一个持有写锁的线程,在修改数据后,获取读锁,然后释放之前持有的写锁。这个过程称之为锁降级

public void processData() {
    rLock.lock();
    if (!update) {
        // 所有线程释放读锁
        rLock.unlock();
        // 只有一个线程会获取写锁
        wLock.lock();
    }
    // 当前线程修改数据
    if (!update) {
        inc(2);
        update = true;
    }
    // 当前线程获取读锁然后释放写锁,完成锁降级
    rLock.lock();
    wLock.unlock();
    // 对修改后的数组进行处理,例如记录
    rLock.unlock();
}

通过锁降级,可以保证对同步数据的每次修改都是可见的,不会出现一个写锁将数据修改之后,另一个写锁再次修改数据。如果应用场景对于每次数据修改都很敏感的话,锁降级是很有必要的。

6、StampedLock

之前的ReadWriteLock、ReentrantLock在读的过程中不允许写入数据,属于悲观锁;而乐观锁允许写进程在读的过程中获取写锁,乐观地认为读的过程中,即便有线程写入数据,也很大概率不会修改正在读的这部分内容。那么万一检测出来数据真的发生修改了怎么办?那就临时加上一个悲观的读锁,然后再读一遍好了。
StampedLock就属于乐观锁的一种,通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取:

public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 释放写锁
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
            stamp = stampedLock.readLock(); // 获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

7、各类锁的比较

synchronized

ReentrantLock

ReentrantReadWriteLock

StampedLock

悲观锁,只允许一条线程进入互斥区

悲观锁,只允许一条线程进入互斥区

悲观锁,有线程进行读取时,不允许其他线程获取写锁进行写入

乐观锁,线程读取时也允许其他线程获取写锁

非公平锁

可以改为公平锁

可以改为公平锁

可以改为公平锁

可重入锁

可重入锁

可重入锁

不可重入锁

不可以中断等待

可以设置超时时间,允许中断等待

使用有限次自旋,增加锁获得的几率

利用Object.wait()和Object.notify()进行线程状态同步

利用Condition.await()和Condition.await()进行线程状态同步;可以组合多个Condition对象绑定多个条件

利用Condition.await()和Condition.await()进行线程状态同步;可以组合多个Condition对象绑定多个条件

利用Condition.await()和Condition.await()进行线程状态同步;可以组合多个Condition对象绑定多个条件

支持锁降级

支持锁降级和锁升级