2019-08-16-Java中实现的锁的区别和简单实现
Java中实现的锁的区别和实现的原理
在开发Java多线程应用程序中,各个线程之间由于要共享资源,必须用到锁机制。Java提供了多种多线程锁机制的实现方式,常见的有synchronized、ReentrantLock、Semaphore、AtomicInteger等。每种机制都有优缺点与各自的适用场景,必须熟练掌握他们的特点才能在Java多线程应用开发时得心应手。
一,synchronized
1,Java锁修饰的不同目标的区别,锁修饰对象有几种:
- 修饰一个类,其作用的范围是synchronized后面括号括起来的部分, 作用的对象是这个类的所有对象
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法, 作用的对象是调用这个方法的对象
- 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象
- 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码, 作用的对象是调用这个代码块的对象
2,使用synchronized修饰的代码具有原子性和可见性,在需要进程同步的程序中使用的频率非常高,可以满足一般的进程同步要求
3,synchronized实现的机理依赖于软件层面上的JVM,因此其性能会随着Java版本的不断升级而提高。
- Java1.5中,synchronized是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。
- Java1.6,synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。
- Java1.7与1.8中,均对该关键字的实现机理做了优化。
4,当线程通过synchronized等待锁时是不能被Thread.interrupt()中断的,因此程序设计时必须检查确保合理,否则可能会造成线程死锁的尴尬境地。
5,尽管Java实现的锁机制有很多种,并且有些锁机制性能也比synchronized高,但还是强烈推荐在多线程应用程序中使用该关键字,因为实现方便,后续工作由JVM来完成,可靠性高。只有在确定锁机制是当前多线程程序的性能瓶颈时,才考虑使用其他机制,如ReentrantLock等。
二,ReentrantLock implements Lock
1,可重入锁,顾名思义,这个锁可以被线程多次重复进入进行获取操作。
- ReentantLock继承接口Lock并实现了接口中定义的方法,
- 除了能完成synchronized所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
- Lock实现的机理依赖于特殊的CPU指令,可以认为不受JVM的约束,并可以通过其他语言平台来完成底层的实现。
- 在并发量较小的多线程应用程序中,ReentrantLock与synchronized性能相差无几,但在高并发量的条件下,synchronized性能会迅速下降几十倍,而ReentrantLock的性能却能依然维持一个水准,因此我们建议在高并发量情况下使用ReentrantLock。
- ReentrantLock通过方法lock()与unlock()来进行加锁与解锁操作,
- 与synchronized会被JVM自动解锁机制不同,ReentrantLock加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作
代码实现如下
private final ReentrantLock lock = new ReentrantLock();
private void lock() {
try {
//设置为不响应中断锁
//lock.lock();
//设置为响应中断锁
lock.lockInterruptibly();
// do something
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
2,线程在等待资源过程中需要中断
- ReentrantLock的在获取锁的过程中有2种锁机制,忽略中断锁和响应中断锁。
- lock.lock()可设置锁机制为忽略中断锁,lock.lockInterruptibly()可设置锁机制为响应中断锁
- 此处响应中断锁是指正在获取锁的过程中,如果线程此时并非处于获取锁的状态,通过此方法设置是无法中断线程的
3,实现可轮询的锁请求
-
在synchronized中,一旦发生死锁,唯一能够恢复的办法只能重新启动程序
-
tryLock()轮询方法来获得锁,如果锁可用则获取锁, 如果锁不可用,则此方法返回false,并不会为了等待锁而阻塞线程,这极大地降低了死锁情况的发生
private void tryLock() { if(lock.tryLock()) { //锁可用,则成功获取锁 try { //获取锁后进行处理 //doSomething(); } catch (Exception e) { e.printStackTrace(); }finally { lock.unlock(); } } else { //锁不可用,其他处理方法 } }
4,定时锁请求,基于3中提到的的lock.tryLock()方法
-
在synchronized中,一旦发起锁请求,该请求就不能停止了,如果不能获得锁,则当前线程会阻塞并等待获得锁
-
Lock就提供了定时锁的机制,使用Lock.tryLock(long timeout, TimeUnit unit)来指定让线程在timeout单位时间内去争取锁资源,如果超过这个时间仍然不能获得锁,则放弃锁请求,定时锁可以避免线程陷入死锁的境地。
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
三,Semaphore 信号量
- 上述两种锁机制类型都是“互斥锁”,学过操作系统的都知道,互斥是进程同步关系的一种特殊情况,相当于只存在一个临界资源,因此同时最多只能给一个线程提供服务。
- 但是,在实际复杂的多线程应用程序中,可能存在多个临界资源,这时候我们可以借助Semaphore信号量来完成多个临界资源的访问。
- Semaphore基本能完成ReentrantLock的所有工作,使用方法也与之类似,通过acquire()与release()方法来获得和释放临界资源
- Semaphone.acquire()方法默认为可响应中断锁,于ReentrantLock.lockInterruptibly()作用效果一致,也就是说在等待临界资源的过程中可以被Thread.interrupt()方法中断。
- Semaphore也实现了可轮询的锁请求与定时锁的功能,除了方法名tryAcquire与tryLock不同,其使用方法与ReentrantLock几乎一致。Semaphore也提供了公平与非公平锁的机制,也可在构造函数中进行设定
- Semaphore的锁释放操作也由手动进行,因此与ReentrantLock一样,为避免线程因抛出异常而无法正常释放锁的情况发生,释放锁的操作也必须在finally代码块中完成
- Semaphore支持多个临界资源,而ReentrantLock只支持一个临界资源。Semaphore的使用方法与ReentrantLock实在太过相似,在此不再举例说明。
四,ReentrantReadWriteLock implements ReadWriteLock
除了Lock接口外,Java的API还提供了另一种读写分离锁,那就是ReadWriteLock。ReadWriteLock是JDK1.5后才引入的,作为读写分离锁,可以有效的帮助减少锁的竞争,提升系统性能。
用锁分离的机制来提升性能比较好理解
- 读-读不互斥:读读之间不阻塞
- 读-写互斥:读阻塞写,写也会阻塞读
- 写-写互斥:写写阻塞
其实就是一句话,可以同时读,但是读写,写读,写写是互斥的
public interface ReadWriteLock {
//读锁
Lock readLock();
//写锁
Lock writeLock();
}
ReadWriteLock是一个接口,其使用的方式和Lock类似
内部都是使用AbstractQueuedSynchronizer即AQS算法来实现的,内部Sync,具体实现可以查看源码
五,StampedLock implements Serializable
StampedLock是java8中新增的类,它是一个更加高效的读写锁的实现,而且它不是基于AQS来实现的,它的内部自成一片逻辑,让我们一起来学习吧。
StampedLock具有三种模式:
- 写模式
- 读模式
- 乐观读模式:乐观读时假定没有其它线程修改数据,读取完成后再检查下版本号有没有变化,没有变化就读取成功了,这种模式更适用于读多写少的场景。
代码实现分别如下:
private final StampedLock lock = new StampedLock();
private int x = 0;
private int y = 0;
/**
* 写锁
* @param moveX
* @param moveY
*/
void tryWriteLock(int moveX, int moveY) {
// 获取写锁,返回一个版本号(戳)
long stampe = lock.tryWriteLock();
x += moveX;
y += moveY;
//释放写锁,需要传入上面获取的版本号
lock.unlockWrite(stampe);
}
/**
* 乐观读
*/
Point tryOptimisticRead() {
//乐观读锁
long stampe = lock.tryOptimisticRead();
int currentX = x;
int currentY = y;
//// 验证版本号是否有变化
if(lock.validate(stampe)) {
currentX = x;
currentY = y;
}
lock.unlockRead(stampe);
return new Point(currentX, currentY);
}
/**
* 读锁
*/
Point tryReadLock(int newX, int newY) {
//// 获取悲观读锁
long stampe = lock.tryReadLock();
while (x == 0 && y == 0) {
// 转为写锁
long ws = lock.tryConvertToWriteLock(stampe);
if(ws != 0) {
// 转换成功
stampe = ws;
x = newX;
y = newY;
break;
} else {
// 转换失败
lock.unlockRead(stampe);
// 获取写锁
stampe = lock.writeLock();
}
}
//释放锁
lock.unlock(stampe);
return new Point(x, y);
}
六,AtomicInteger
- 此处AtomicInteger是一系列相同类的代表之一,常见的还有AtomicLong、AtomicLong等,他们的实现原理相同,区别在与运算对象类型的不同。
- 令人兴奋地,还可以通过AtomicReference<V>将一个对象的所有操作转化成原子操作。
- 通常AtomicInteger的性能是ReentantLock的好几倍。
七,volatile关键字说明
volatile属于稍弱线程同步方式,但是AtomicInteger的实现又离不开volatile实现的机制,此处对volatile进行说明
- 加锁机制(即同步机制)既可以确保可见性又可以确保原子性
- volatile只保证变量的可见性,不保证原子性。就是说多线程访问和修改volatile修饰的变量,修改之后的值对于其他线程是可见的
- 当且仅当满足以下所有条件时,才应该使用 volatile 变量:对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
该变量没有包含在具有其他变量的不变式中。 - 解决的问题:在当前的 Java 内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
- 就需要把变量声明为 volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取
- 锁和volatile的区别:volatile,变量是一种稍弱的同步机制在访问,volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞。