程序的锁一览
企业应用的代码一般是BIO模型的隔离的线程下运行的,因为并发量不高,所以遇到多线程共享资源的情况并不多,那就可以不去了解安全地使用共享资源吗?我们知道程序最重要的指标就是性能,除了恰当使用数据结构和算法降低时间复杂度和空间复杂度外,还要会使用多线程技术充分利用机器的多个CPU提高性能。在多线程的环境下发生数据错乱的情况到处可见,所以能准确使用锁有序访问共享资源也是编程中一项很重要的技能。
在讲锁之前先看一下线程,因为锁是在线程之间起作用的。众所周知,线程是CPU调度的基本单位,进程是资源分配的基本单位。同一进程的各个线程间共享内存和文件资源,线程间传递数据时无需经过内核。线程通过系统调度使用CPU上的时间片执行程序,由于CPU能在很短的时间内快速切换线程,即使是单核CPU也看起来像多个任务同时在执行的样子。以下是多线程场景下简单的累加程序,经常被拿来说明共享数据错乱的问题:
public class NoneSyncLock {
static int count = 0;
public static void count() {
for (int i = 0; i < 1000; i++) {
count++;
}
}
public static void main(String[] args) {
List<Thread> list = new ArrayList<>();
try {
for (int i = 0; i < 4; i++) {
Thread thread = new Thread(() -> count());
thread.start();
list.add(thread);
}
for(Thread t : list) {
t.join();
}
System.out.println(count);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
上述程序正确结果是4000,可是偶尔会出现少于4000的情况?这是因为i++操作是分为三步完成自身加1的操作:

在多线程的场景中,基于CPU时间片的线程有一定概率在内存中获取到相同的i到寄存器中进行加1的操作,也就是有一小部分操作重复计算到相同的数值写回内存,造成数据丢失的问题,所以就会出现总的数值少于4000的情况。上面对共享变量i++的操作容易发生竞争状态,该段代码被临界区(critical section)。我们希望任意时间只有一个线程能进入临界区进行操作,具体的做法就是线程在进入临界区之前加锁来保证。那加锁就只是简单的加锁和解锁就可以了吗?事实上是没有那么简单。开发中会遇到各种锁的问题,下文介绍一些常见锁的概念。
死锁
死锁是发生在为多个资源加锁的过程中,线程之间互相等待对方释放锁的情况。满足死锁的四个条件有:
- 互斥条件:任意时刻该资源只能由一个线程拥有
- 请求和保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
- 不可剥夺条件:线程已获得资源未使用完之前不能被其他线程强行剥夺,只有使用完资源后才释放资源
- 循环等待条件:多个线程之间形成一种首尾相接循环等待资源的关系
怎样避免避免线程死锁?
- 破坏请求和保持条件:一次性申请所有资源
- 破坏不可剥夺条件:占用部分资源的的线程进一步申请其他资源时,如果申请不到,主动释放占有的资源
- 破坏循环等待条件:按照某一顺序申请资源,释放资源则反序释放
活锁和锁饥饿
活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。 活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
悲观锁和乐观锁
悲观锁在获取共享资源前先要上锁,其他线程获取资源失败会进入阻塞的状态。乐观锁认为操作不会产生并发问题,所以并没有加锁,它先获取当前版本,再执行操作,最后验证版本与之前读取版本是否一致,如果一致则继续修改,否则修改失败。乐观锁看起来和自旋锁相似,两者都有不断重试的过程,但实际上它们是两种不同的情况。自旋锁是真真正正的加锁,只是不断地重试加锁,而乐观锁是没有加锁的,通过比较版本号是否被修改来更新数据。
自旋锁和互斥锁
常见的锁是有层级的,最底层的两种锁是互斥锁和自旋锁。自旋锁属于忙等待锁,互斥锁是无忙等待锁,它们都属于悲观锁。自旋锁是指在加锁的过程中利用CPU时间片不断重试加锁,而互斥锁在线程加锁失败后线程就进入阻塞状态。现在假设同步互斥锁(Mutex refernce)延迟大概在25ns左右,而临界区代码的操作时间远小于25ns,这时候应该使用互斥锁还是自旋锁呢?很明显使用忙等待的自旋锁不但能避免线程阻塞而且能快速加锁,而,而自带上下文切换的互斥锁时间比锁住的代码段执行时间还长。下面介绍Java中的加锁两种不同的方式,自动加锁和解锁的sychronized和需要手动加解锁的reentrantLock:
public class SynchronizedLock extends Worker implements LockStyle {
@Override
public void lockAndRun(Resource resource) {
/*synchronized关键字加锁分为以下几种情况:
1.synchronized (this) 是在所在类的对象上加锁
2.synchronized (Worker.class) 是给指定的类加锁
3.普通方法synchronized加锁的对象this(同情况1),静态方法加锁的对象是当前类(同上情况2)
4.Object obj = new Object();synchronized(obj),在指定的对象上加锁*/
synchronized (SynchronizedLock.class) {
resource.apply();
}
}
}
public class ReentrantRawLock extends Worker implements LockStyle {
@Override
public void lockAndRun(Resource resource) {
try {
resource.reentrantLock.lock();
resource.apply();
} finally {
resource.reentrantLock.unlock();
}
}
}
读锁和写锁
读写锁(Readers-Writer Lock)顾名思义是一把锁分为两部分:读锁和写锁。因为读操作本身是线程安全的,允许多个线程同时获得,而写锁则是互斥锁,不允许多个线程同时获得写锁,并且写操作和读操作也是互斥的,下表显示了两个线程读写操作互斥的情况:
| 线程操作 | 读 | 写 |
|---|---|---|
| 读 | 不互斥 | 互斥 |
| 写 | 互斥 | 互斥 |
可知读写锁的特点是:读读不互斥、读写互斥、写写互斥。如果在不使用读写锁,两个线程在临界区需要等待上一个线程离开后才能获取锁操作,既不发生冲突的读读操作没法并发执行,当共享资源在读多写少的场景下,读读并发执行的概率大,所以使用读写锁获得的性能提升会更多。
public class ReadWriteRawLock extends Worker implements LockStyle {
//读写锁
public static final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public final Lock readLock = readWriteLock.readLock();
public final Lock writeLock = readWriteLock.writeLock();
/**
* Java read and write lock
*/
public void lockAndRun(Resource resource) {
String res = null, key = resource.getKey();
readLock.lock();
try {
((CacheResource) resource).readData(resource.getKey());
} catch (Exception ex) {
ex.printStackTrace();
} finally {
readLock.unlock();
}
if (Objects.nonNull(res)) {
System.out.println(currentThreadName() + "缓存中 key " + key + "的值为:" + res);
}
writeLock.lock();
try {
((CacheResource) resource).writeData(resource.getKey());
} catch (Exception ex) {
ex.printStackTrace();
} finally {
writeLock.unlock();
}
}
}
生产者与消费者
生产者与消费者问题中线程之间既存在互斥又存在同步。生产者获取锁后检查当前是否满足生产条件,如果不满足则释放锁阻塞,反之进行生产。同理消费者获取锁后检查当前是否满足消费条件,不满足则释放锁阻塞,反之进行消费。

下面代码中的full条件和empty条件就是为了阻塞和唤醒线程,使消费者和生产者有序运行:
public class ReentrantLockResource extends Resource<LinkedList<String>> {
private int limit = 10;
public final ReentrantLock reentrantLock = new ReentrantLock();
//声明condition是为了当满足条件时,将对应的线程阻塞
Condition full = reentrantLock.newCondition();
Condition empty = reentrantLock.newCondition();
//生产方法
public void produce() {
reentrantLock.lock();
try {
int idx = counter.get();
while (data.size() >= limit) {
try {
System.out.println(currentThreadName() + "第 " + idx + "次添加元素,List已满,等待添加元素");
full.await();//
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(currentThreadName() + "第 " + idx + "次添加元素");
counter.set(idx + 1);
data.add("123");
empty.signalAll();
} finally {
reentrantLock.unlock();
}
}
//消费方法
public void consume() {
reentrantLock.lock();
try {
int idx = counter.get();
while (data.size() == 0) {
try {
System.out.println(currentThreadName() + "第 " + idx + "次消费元素,List已空,等待消费元素");
empty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(currentThreadName() + "第 " + idx + "次消费元素");
data.removeFirst();
counter.set(idx + 1);
full.signalAll();
} finally {
reentrantLock.unlock();
}
}
}
public class Consumer implements Runnable {
Resource resource;
public Consumer(Resource resource) {
this.resource = resource;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(2000);
resource.consume();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
}
public class Producer implements Runnable {
Resource resource;
public Producer(Resource resource) {
this.resource = resource;
}
@Override
public void run() {
while (true) {
try {
Thread.sleep(500);
resource.produce();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
}
分布式锁
一般情况下,单机环境多线程使用编程语言提供的锁就可以解决共享资源的并发问题。然而在分布式环境中共享资源是发生在跨网络的多台机器之间,在加解锁需要考虑网络抖动、请求超时、数据包丢失与客户崩溃等问题,所以加解锁的操作比单机要复杂一点。以下是Redis分布式锁的实现代码:
//redis加锁
public String acquireLockWithTimeout(Jedis conn, String lockName, int acquireTimeout, int lockTimeout) {
String id = UUID.randomUUID().toString();
lockName = "lock:" + lockName;
long end = System.currentTimeMillis() + (acquireTimeout * 1000);
while (System.currentTimeMillis() < end) {
if (conn.setnx(lockName, id) >= 1) {
conn.expire(lockName, lockTimeout);
System.out.println(currentThreadName() + "获取到锁");
return id;
} else if (conn.ttl(lockName) <= 0) {
conn.expire(lockName, lockTimeout);
}
try {
Thread.sleep(1);
} catch (InterruptedException ie) {
Thread.interrupted();
}
}
return null;
}
//redis解锁
public boolean releaseLock(Jedis conn, String lockName, String identifier) {
if (identifier == null) return false;
lockName = "lock:" + lockName;
while (true) {
conn.watch(lockName);
if (identifier.equals(conn.get(lockName))) {
Transaction trans = conn.multi();
trans.del(lockName);
List<Object> result = trans.exec();
// null response indicates that the transaction was aborted due
// to the watched key changing.
if (result == null) {
continue;
}
System.out.println(currentThreadName() + "释放锁");
return true;
}
conn.unwatch();
break;
}
return false;
}
示例中redis加锁方法acquireLockWithTimeout中使用setnx命令(多个客户端同时设置只会有一个客户端返回成功)为代表锁的键设置一个值,以此来获取锁;当获取锁失败时,函数会在给定的时间内进行重试,直到成功获取锁或超过给定的时限为止。为了避免锁的持有者崩溃时锁保持占用的问题,需要给锁加上超时时间,即使客户端加锁后崩溃没有执行设置过期时间也没有关系,因为其他客户端会帮忙设置,所以最后锁会超时并释放。解锁方法releaseLock在解锁时需要传入锁标识,只有锁标识与建值相同的情况下才能正常解锁,这是为了避免将其他线程持有的锁释放,导致共享资源被多个进程同时占用的情况。
在多线程场景下既有同步操作又有互斥操作。锁的存在是为了保证共享资源被有序的使用,避免多线程或多进程同时操作共享资源的情况。同步操作一般是在互斥的基础上实现的,使参与的线程或进程按照指定的顺序运行。所以熟悉常见的加锁方式十分重要。本文中概念部分借鉴了很多文章再经过自己的理解编写,难免会有错误,如有问题请借鉴修正,代码可以从这里下载测试,谢谢!
浙公网安备 33010602011771号