java并发编程的场景和使用锁的必要性
作为程序员,对“并发”这个词语都不陌生,当很多个请求同时打到我们的应用上时,我们的应用需要“同时”的执行计算,对这些请求进行处理。当然这里的“同时”打了双引号,因为是给人的感觉好像同时在处理那么多的请求,但是我们的计算机其实是交替的处理多个任务,比如任务A先执行3us,把A暂停,由任务B再执行5us,再切换到任务C。只是因为我们的计算机计算的太快,我们感觉好像是“同时”在执行那么多的任务。有兴趣的同学可以了解一下“并发”和“并行”区别。
并发可以提高系统的吞吐量和响应速度,充分利用计算资源,提高系统的性能和效率。在Java应用中,具体的体现是我们会设置多个线程会同时执行任务,有时候为了提高系统的应对并发场景的能力,我们甚至会设置一个“线程池”对同样的任务进行执行,减少创建线程和销毁线程的额外开销。
但是并发编程也有很多需要解决的问题,其中的一个重要的问题就是不同的线程可能会对同一个资源进行访问和修改,这样会导致线程阻塞、死锁、资源争用、数据不一致等一系列问题。因为我们在编码时需要对并发问题进行考虑和解决。
在编程中,我们常用的方式时使用“锁”对多线程的执行进行限制和处理,保证每个线程的运行顺序和数据的一致性。
锁的分类
Java开发中,锁的种类大致分为两类:悲观锁和乐观锁。
悲观锁:
如其名,线程对其他线程是否会占用自己需要的资源持“悲观”态度,认为别的线程一定会占用自己需要的共享资源,因此需要对其他线程采取“互斥”的态度,每次访问共享资源时总是需要加锁,使一段时间内只有一个线程可以访问共享资源,保证数据的一致性。悲观锁的典型实现是使用互斥锁(如synchronized关键字)或写锁,它们保证同一时间只有一个线程可以访问共享资源,其他线程需要等待锁的释放。悲观锁适合用于写操作频繁的场景。
乐观锁:
与悲观锁相反,乐观锁是一种乐观的思想,它认为在并发环境下,冲突是少数情况,因此不会使用互斥锁来保护共享资源,而是通过CAS(compare and swap,这里的swap可以理解为写的意思)的方式来保证数据的一致性。基本思想是:先读数据(连着版本号或者时间戳一起读,简称标识),进行修改操作的时候,会比较之前读的标识和当前数据的标识是否一致,一致则说明没有人修改过,进行修改操作并写入新的标识(通常是版本号+1,或者记录当前时间戳);不一致则重新尝试获取锁,直到标识一致为止。这里必须强调,乐观锁适用于读多写少的场景。
分布式锁
但是在分布式的场景中,一般的锁可能并不管用了,因为我们的程序一般部署在多台机器上,是多台机器的多线程在同时抢占一份资源,传统的锁只能锁住一台机器的线程,多台机器的线程就不适用了。
这时我们就需要使用“分布式锁”。接下来介绍基于数据库实现分布式锁的悲观锁和乐观锁。
基于数据库实现分布式锁
悲观锁
基本思想是我们在数据库中记录 需要上锁的资源主键、上锁状态 和 超时时间。通过数据库的select...for update操作来实现对资源上锁。(不了解select...for update操作同学可自行百度)
数据库的表结构如下:表名:source_lock
资源主键(唯一主键) | 是否上锁 | 锁的开始时间 | 锁的超时时间 |
resource_key | is_lock | start_time | time_out |
加锁的代码如下:
//一定要加事务,保证过程是原子操作
@Transcational
public lock(key){
resource = "select * from source_lock where resource_key = {key} for update";
//没获取到锁则进行加锁操作
if(resource == null) {
resource = new Resource();
resource.setKey(key);
//设置状态为上锁
resource.setIsLock(1);
//获取当前时间
resource.setStartTime = new Data();
//假设锁的有效时间为3s
resource.setTimeOut(3000);
//加锁
int count = "insert resource into source_lock"
//加锁成功
if(count == 1){
return ture;
}else{
return false;
}
}
//获取到锁,需要判断是否是上锁状态,以及是否过期。
if(resource.getIsLock() == '0' && resource.getStartTime() + resource.getTimeOut() < new Data()){
resource = new Resource();
resource.setKey(key);
//设置状态为上锁
resource.setIsLock(1);
//获取当前时间
resource.setStartTime = new Data();
//假设锁的有效时间为3s
resource.setTimeOut(3000);
//加锁
int count = "update into source_lock set resource";
return ture;
}else if(resource.getStartTime() + resource.getTimeOut() < new Data()){
//超时业务未处理完成,获取锁失败
return false;
}else{
//获取锁失败
return false;
}
}
解锁的代码如下:
//一定要加事物,保证过程是原子操作
@Transcational
public unlock(key) {
resource = "select * from source_lock where resource_key = {key} for update";
resource.setIsLock(0);
//解锁
int count = "update into source_lock set resource";
return;
}
具体应用锁的代码如下:
try{
while(true){
//加锁
if(lock(key)){
//执行业务逻辑
break;
}
}
} final {
//解锁
unlock(key)
}
需要注意的是,在这个例子中,如果线程获取了锁,但是超时时间内没有返回的话,其他的线程也不会获得锁,可能会造成死锁的情况。也可以设置线程超时,其他的线程会自动获得锁,解锁时,会先根据是否超时判断是否要解锁,防止误解别的线程加的锁,但是这样会导致上一个线程还未执行完毕,共享资源就被其他线程抢占,可能造成脏数据的问题。因此具体使用哪种要根据业务场景来看。
乐观锁
基本思想是不锁定资源,默认不会发生冲突。加一个版本号,每次更新操作让版本号+1,写数据时需要先对比版本号,如果版本号和获取的不一样则加锁失败,就要重新获取锁。
表结构如下:
资源主键(唯一主键) | 版本号 | 资源内容 |
resource_key | version | content |
伪代码如下:
while(true){
//先读取旧内容,记录版本号
oldResource = "select * from source_lock where resource_key = {key}";
oldVersion = oldResource.getVersion();
//执行业务逻辑
newContent = process(oldResource.getContent());
//更新内容
int count = update source_lock set newContent, version+1 where resource_key = oldResourc.getResourceKey() and version = oldVersion;
if(count == 1){
//更新成功则结束
break;
}
//否则重试
}
乐观锁实现比较简单,但是比较适合写操作比较少的场合,否则可能会导致重试的次数很多,但是大量的线程阻塞在死循环内,影响性能。
注意:更新时,比较和更新(Compare and swap)必须用SQL同时进行,不能用if判断,因为if不具备原子性,无法保证比较时相等,比较之后被别的线程抢占,改变了version的值。
总结
悲观锁和乐观锁都有其应用场景,只有找对了应用场景,才能保证程序的功能和性能。除了数据库的实现方式,分布式锁还有Redis、Zookeeper、消息队列等实现方式,可以根据自己的要求合理使用。但是合理利用这些锁的前提是了解思想与原理,技术没有好坏之分,也没有绝对的优势与劣势,大多数都是取决于应用的场景和我们的需求。
参考文档: