单机环境下对共享资源的同步访问可以用java提供的api去实现。
一、synchronized关键字
这应该是java开发人员很熟悉的一个关键字了,可以用在代码块和方法上,保证代码或方法的同步运行。
看如下一个简单的代码示例,使用了十个线程,每个线程修改十次资源:
public class LockTest {
public static void main(String[] args) {
Haha hh = new Haha();
CountDownLatch cdl = new CountDownLatch(10);
for(int i=0 ;i<10;i++) {
new Thread(new Runnable() {
@Override
public void run() {
for(int j=0;j<10;j++) {
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
hh.addIndex();
}
cdl.countDown();
}
}).start();
}
try {
cdl.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("final index=" + hh.getIndex());
}
}
class Haha{
private static int index=0;
public void addIndex() {
System.out.println(++index);
}
public int getIndex() {
return index;
}
}
以上代码逻辑,我们十个线程总共运行100次addIndex(),期望值最后index=100,实际上最后的打印final index= 经常会小于100,这就是多线程下共享资源使用不同步的结果,利用synchronized将代码同步,修改为:
public synchronized void addIndex() {
System.out.println(++index);
}
或者:
public void addIndex() {
synchronized (this) {
System.out.println(++index);
}
}
再运行后,final index= 的打印结果就能准确的为100
以上代码,我们注意,index的定义是个static,也就是说所有的Haha类共享同一个变量,那如果我们多个线程执行多个Haha实例,会不会有问题,我们将上面的代码改造下,将一开始的实例化,放到线程中去实例,也就是说每个线程都有一个Haha实例:
public class LockTest {
public static void main(String[] args) {
CountDownLatch cdl = new CountDownLatch(10);
for(int i=0 ;i<10;i++) {
new Thread(new Runnable() {
@Override
public void run() {
Haha hh = new Haha();
for(int j=0;j<10;j++) {
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
hh.addIndex();
}
cdl.countDown();
}
}).start();
}
try {
cdl.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
Haha hh = new Haha();
System.out.println("final index=" + hh.getIndex());
}
}
class Haha{
private static int index=0;
public void addIndex() {
synchronized (this) {
System.out.println(++index);
}
}
public int getIndex() {
return index;
}
}
虽然我们的addIndex()方法有synchronized关键字,但是打印结果仍然不理想,最后的index仍然不为100,这是为什么呢?我们注意到synchronized后面的(this),这个this表示当前实例对象,每个线程的实例中锁不一样,自然不会互相影响,所以这里我们应该改为:
public void addIndex() {
synchronized (Haha.class) {
System.out.println(++index);
}
}
所以使用synchronized关键字的时候要注意:
a.对于普通方法,synchronized使用的默认是当前对象实例锁;
b.对于静态方法,synchronized使用的默认是当前类对象锁;
c.对于代码块,synchronized使用的锁就是括号中指定的锁,这里要特别注意,如果共享资源是对象实例级别,可以用this,但是如果资源是类的所有对象级别,那么要用全局唯一锁,如Haha.class
二、ReentrantLock
ReentrantLock是java.util.concurrent包下提供的一套互斥锁,通过lock和unlock方法手动获取锁和释放锁,代码使用很简单:
private static Lock l = new ReentrantLock();
public void addIndex() {
l.lock();
try {
System.out.println(++index);
} finally {
l.unlock();
}
}
ReentrantLock的创建根据实际需求判断是对象实例级别还是类级别,合理使用static定义,为了避免因代码异常导致的死锁,我们需要在finally代码块里释放锁。
相比于synchronized关键字是属于非公平锁,ReentrantLock默认也是非公平锁,但是可以使用公平锁(所有线程按请求锁的顺序获取锁)
使用公平锁的方式:
Lock l = new ReentrantLock(false);
三、ReentrantReadWriteLock
ReentrantReadWriteLock也是java.util.cncurrent包下提供的一种锁实现,上面提到的synchronized和ReentrantLock都是属于排他锁,任何线程对资源的访问都是同步发生,而ReentrantReadWriteLock读写分开,运行多个线程同时读,但是不允许读写、写写的线程同时访问,大大提高了并发性,由于我也没怎么用过,此处不多说明,希望后续能够详细了解后再整理出来。
以上同步锁很好的解决单机环境下并发使用资源共享的问题,在分布式系统中,这些锁就无能为力了,此时,我们就需要一个能够实现整个项目中全局占用的锁,也就是我们通常说的分布式锁,我们可以通过以下几个基本原则,来实现非公平锁:
1.同一时间只能有一个客户端能获取到锁;
2.要避免死锁;
3.a线程占用的锁,不能被b线程给解掉;
4.锁的性能问题以及可用性;
基于以上的原则,我们可以自己去实现分布式锁,以下是几种锁的实现思路:
四、利用数据库唯一主键
可以定义一个只包含唯一主键的表,使用共享资源前,往库里插入一条指定的lock记录,如果插入成功表示获取到锁,插入失败,意味着主键冲突,说明其他线程已经获取锁,释放锁就是删除这条记录。
先创建一个锁表db_lock,其中lock_name就是锁的名字,具有唯一性,lock_time表示占用锁的时间,用来判断锁是否超时:
create table db_lock(
lock_name varchar(100) not null primary key,
lock_time datetime
)engine=innodb default charset=utf8;
以下用mybatis简单实现锁,提供部分代码:
<mapper namespace="lin.db.test.mybatis.DbLockDao">
<select id="addLock" parameterType="string">
insert into db_lock (lock_name,lock_time) values (#{lock},current_timestamp)
</select>
<delete id="delLock" parameterType="string">
delete from db_lock where lock_name = #{lock}
</delete>
</mapper>
public class DbLock {
private static SqlSessionFactory sqlSessionFactory = null;
//引入threadLocal记录当前线程占用锁情况,可以避免当前线程解非自己占用的锁
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
static {
try {
InputStream is = Resources.getResourceAsStream("mybatis-config.xml");
sqlSessionFactory = new SqlSessionFactoryBuilder().build(is);
} catch (IOException e) {
e.printStackTrace();
}
}
/** 获取锁 */
public static boolean lock(String lock) {
Objects.requireNonNull(lock);
SqlSession sqlSession = null;
try {
//向数据库插入一条数据,成功表示获取到锁
sqlSession = sqlSessionFactory.openSession();
DbLockDao dbLockDao = sqlSession.getMapper(DbLockDao.class);
dbLockDao.addLock(lock);
sqlSession.commit();
//获取到锁,把锁记录到threadLocal中
threadLocal.set(lock);
return true;
} catch (Exception e) {
//失败表示锁被其他线程获取
return false;
} finally {
if(sqlSession != null) {
sqlSession.close();
}
}
}
/** 释放锁 */
public static void unlock(String lock) {
Objects.requireNonNull(lock);
//解锁时判断是不是当前线程占用了这个锁
if(!lock.equals(threadLocal.get())) {
System.out.println(Thread.currentThread().getName() + " 非当前线程占用锁,无法解锁");
return;
}
SqlSession sqlSession = null;
try {
sqlSession = sqlSessionFactory.openSession();
DbLockDao dbLockDao = sqlSession.getMapper(DbLockDao.class);
dbLockDao.delLock(lock);
sqlSession.commit();
//解锁完成把占用锁标识删除
threadLocal.remove();
} catch (Exception e) {
e.printStackTrace();
} finally {
if(sqlSession != null) {
sqlSession.close();
}
}
}
public static void main(String[] args) {
String lockName = "testLock";
for(int i = 0 ;i<3; i++) {
new Thread(new Runnable() {
@Override
public void run() {
while(true) {
if(lock(lockName)) {
System.out.println(Thread.currentThread().getName() + " 获取到锁");
try {
//获取到锁后开始执行业务逻辑
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}finally {
//释放锁,记得要在try-finaly里释放,避免死锁
System.out.println(Thread.currentThread().getName() + " 释放了锁");
unlock(lockName);
}
break;
}
}
}
}).start();
}
}
}
结合之前我们提到的几点原则,对比代码:
1.利用了数据库主键冲突来保证同一时间只有一个客户端能占用锁;
2.try-finally代码块中释放锁,避免死锁,不过当线程中途突然停止后,仍然会有死锁,所以库里记录了时间字段,我们可以在数据库部署一个监控脚本,去定时释放锁,或者其他大家能想到的办法,只要保证锁不会一直被占用;
3.代码中ThreadLocal相关的代码片段就是用来保证当前客户端不能解锁非当前客户端占用的锁;
4.至于可用性,就靠数据库本身的可用性了。
一般来说,不会用关系型数据库来实现锁的,频繁访问数据库性能上会比较差,但是我们可以学习这种锁设计的思想。
五、利用redis实现分布式锁
redis相比关系型数据库,性能上很快,用它来实现锁,更方便简单,redis的集群也能保证很好的可用性。
以下上相关代码片段供参考:
public class LockUtil {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
private static final int DEFAULT_TIMEOUT_SECONDS = 60; //锁失效时间60秒
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_EXPIRE_SECOND = "EX";
private static final String SET_RESULT_OK = "OK";
/**
* 获取锁
* @param lock
* @return
*/
public static boolean lock(String lock) {
return lock(lock, DEFAULT_TIMEOUT_SECONDS);
}
/**
* 获取锁
* @param lock
* @param lockTimeOutSeconds 锁超时时间,单位秒
* @return
*/
public static boolean lock(String lock,int timeOutSeconds) {
Objects.requireNonNull(lock);
String owner = UUID.randomUUID().toString();
String r = RedisClusterClient.getClient().set(lock, owner, SET_IF_NOT_EXIST, SET_EXPIRE_SECOND, timeOutSeconds);
if( SET_RESULT_OK.equals(r) ) {
threadLocal.set(owner);
return true;
}
return false;
}
/**
* 释放锁
* @param lock
*/
public static void unLock(String lock) {
Objects.requireNonNull(lock);
String owner = threadLocal.get();
//利用lua脚本实现 判断+删除 的原子操作
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
RedisClusterClient.getClient().eval(script, Collections.singletonList(lock), Collections.singletonList(owner));
}
}
不管什么工具或API或第三方接口,只要能实现整个分布式系统的全局唯一,理论上我们都可以用来设计分布式锁,思想都是差不多的,比如,我们也可以用zookeeper去实现分布式锁,也可以用memcache去实现,以上代码案例都是简单的非公平锁实现,可以在此基础上实现更复杂的锁机制,当然前提条件是你对其中的原理很熟悉。