Java 锁的知识总结及实例代码
作者:mini188
java中有哪些锁
这个问题在我看了一遍<java并发编程>后尽然无法回答,说明自己对于锁的概念了解的不够。于是再次翻看了一下书里的内容,突然有点打开脑门的感觉。看来确实是要学习的最好方式是要带着问题去学,并且解决问题。
在java中锁主要两类:内部锁synchronized和显示锁java.util.concurrent.locks.Lock。但细细想这貌似总结的也不太对。应该是由java内置的锁和concurrent实现的一系列锁。
为什么这说,因为在java中一切都是对象,而java对每个对象都内置了一个锁,也可以称为对象锁/内部锁。通过synchronized来完成相关的锁操作。
而因为synchronized的实现有些缺陷以及并发场景的复杂性,有人开发了一种显式的锁,而这些锁都是由java.util.concurrent.locks.Lock派生出来的。当然目前已经内置到了JDK1.5及之后的版本中。
synchronized
首先来看看用的比较多的synchronized,我的日常工作中大多用的也是它。synchronized是用于为某个代码块的提供锁机制,在java的对象中会隐式的拥有一个锁,这个锁被称为内置锁(intrinsic)或监视器锁(monitor locks)。线程在进入被synchronized保护的块之前自动获得这个锁,直到完成代码后(也可能是异常)自动释放锁。内置锁是互斥的,一个锁同时只能被一个线程持有,这也就会导致多线程下,锁被持有后后面的线程会阻塞。正因此实现了对代码的线程安全保证了原子性。
可重入
既然java内置锁是互斥的而且后面的线程会导致阻塞,那么如果持有锁的线程再次进入试图获得这个锁时会如何呢?比如下面的一种情况:
public class BaseClass { public synchronized void do() { System.out.println("is base"); } } public class SonClass extends BaseClass { public synchronized void do() { System.out.println("is son"); super.do(); } } SonClass son = new SonClass(); son.do();
此时派生类的do方法除了会首先会持有一次锁,然后在调用super.do()的时候又会再一次进入锁并去持有,如果锁是互斥的话此时就应该死锁了。
但结果却不是这样的,这是因为内部锁是具有可重入的特性,也就是锁实现了一个重入机制,引用计数管理。当线程1持有了对象的锁a,此时会对锁a的引用计算加1。然后当线程1再次获得锁a时,线程1还是持有锁a的那么计算会加1。当然每次退出同步块时会减1,直到为0时释放锁。
synchronized的一些特点
修饰代码的方式
修饰方法
public class BaseClass { public synchronized void do() { System.out.println("is base"); } }
这种就是直接对某个方法进行加锁,进入这个方法块时需要获得锁。
修饰代码块
public class BaseClass { private static Object lock = new Object(); public void do() { synchronized (lock) { System.out.println("is base"); } } }
这里就将锁的范围减少到了方法中的部分代码块,这对于锁的灵活性就提高了,毕竟锁的粒度控制也是锁的一个关键问题。
对象锁的类型
经常看到一些代码中对synchronized使用比较特别,看一下如下的代码:
public class BaseClass { private static Object lock = new Object(); public void do() { synchronized (lock) { } } public synchronized void doVoid() { } public synchronized static void doStaticVoid() { } public static void doStaticVoid() { synchronized (BaseClass.class) { } } }
这里出现了四种情况:修饰代码块,修饰了方法,修饰了静态方法,修饰BaseClass的class对象。那这几种情况会有什么不同呢?
修饰代码块
这种情况下我们创建了一个对象lock,在代码中使用synchronized(lock)这种形式,它的意思是使用lock这个对象的内置锁。这种情况下就将锁的控制交给了一个对象。当然这种情况还有一种方式:
public void do() { synchronized (this) { System.out.println("is base"); } }
使用this的意思就是当前对象的锁。这里也道出了内置锁的关键,我提供一把锁来保护这块代码,无论哪个线程来都面对同一把锁咯。
修饰对象方法
这种直接修饰在方法是咱个情况?其实和修饰代码块类似,只不过此时默认使用的是this,也就是当前对象的锁。这样写起代码来倒也比较简单明确。前面说过了与修饰代码块的区别主要还是控制粒度的区别。
修饰静态方法
静态方法难道有啥不一样吗?确实是不一样的,此时获取的锁已经不是this了,而this对象指向的class,也就是类锁。因为Java中的类信息会加载到方法常量区,全局是唯一的。这其实就提供了一种全局的锁。
修饰类的Class对象
这种情况其实和修改静态方法时比较类似,只不过还是一个道理这种方式可以提供更灵活的控制粒度。
小结
通过这几种情况的分析与理解,其实可以看内置锁的主要核心理念就是为一块代码提供一个可以用于互斥的锁,起到类似于开关的功能。
java中对内置锁也提供了一些实现,主要的特点就是java都是对象,而每个对象都有锁,所以可以根据情况选择用什么样的锁。
java.util.concurrent.locks.Lock
前面看了synchronized,大部分的情况下差不多就够啦,但是现在系统在并发编程中复杂性是越来越高,所以总是有许多场景synchronized处理起来会比较费劲。或者像<java并发编程>中说的那样,concurrent中的lock是对内部锁的一种补充,提供了更多的一些高级特性。
java.util.concurrent.locks.Lock简单分析
这个接口抽象了锁的主要操作,也因此让从Lock派生的锁具备了这些基本的特性:无条件的、可轮循的、定时的、可中断的。而且加锁与解锁的操作都是显式进行。下面是它的代码:
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
ReentrantLock
ReentrantLock就是可重入锁,连名字都这么显式。ReentrantLock提供了和synchronized类似的语义,但是ReentrantLock必须显式的调用,比如:
public class BaseClass { private Lock lock = new ReentrantLock(); public void do() { lock.lock(); try { //.... } finally { lock.unlock(); } } }
这种方式对于代码阅读来说还是比较清楚的,只不过有个问题,就是如果忘了加try finally或忘 了写lock.unlock()的话导致锁没释放,很有可能导致一些死锁的情况,synchronized就没有这个风险。
trylock
ReentrantLock是实现Lock接口,所以自然就拥有它的那些特性,其中就有trylock。trylock就是尝试获取锁,如果锁已经被其他线程占用那么立即返回false,如果没有那么应该占用它并返回true,表示拿到锁啦。
另一个trylock方法里带了参数,这个方法的作用是指定一个时间,表示在这个时间内一直尝试去获得锁,如果到时间还没有拿到就放弃。
因为trylock对锁并不是一直阻塞等待的,所以可以更多的规避死锁的发生。
lockInterruptibly
lockInterruptibly是在线程获取锁时优先响应中断,如果检测到中断抛出中断异常由上层代码去处理。这种情况下就为一种轮循的锁提供了退出机制。为了更好理解可中断的锁操作,写了一个demo来理解。
package com.test; import java.util.Date; import java.util.concurrent.locks.ReentrantLock; public class TestLockInterruptibly { static ReentrantLock lock = new ReentrantLock(); public static void main(String[] args) { Thread thread1 = new Thread(new Runnable() { @Override public void run() { try { doPrint("thread 1 get lock."); do123(); doPrint("thread 1 end."); } catch (InterruptedException e) { doPrint("thread 1 is interrupted."); } } }); Thread thread2 = new Thread(new Runnable() { @Override public void run() { try { doPrint("thread 2 get lock."); do123(); doPrint("thread 2 end."); } catch (InterruptedException e) { doPrint("thread 2 is interrupted."); } } }); thread1.setName("thread1"); thread2.setName("thread2"); thread1.start(); try { Thread.sleep(100);//等待一会使得thread1会在thread2前面执行 } catch (InterruptedException e) { e.printStackTrace(); } thread2.start(); } private static void do123() throws InterruptedException { lock.lockInterruptibly(); doPrint(Thread.currentThread().getName() + " is locked."); try { doPrint(Thread.currentThread().getName() + " doSoming1...."); Thread.sleep(5000);//等待几秒方便查看线程的先后顺序 doPrint(Thread.currentThread().getName() + " doSoming2...."); doPrint(Thread.currentThread().getName() + " is finished."); } finally { lock.unlock(); } } private static void doPrint(String text) { System.out.println((new Date()).toLocaleString() + " : " + text); } }
上面代码中有两个线程,thread1比thread2更早启动,为了能看到拿锁的过程将上锁的代码sleep了5秒钟,这样就可以感受到前后两个线程进入获取锁的过程。最终上面的代码运行结果如下:
2016-9-28 15:12:56 : thread 1 get lock.
2016-9-28 15:12:56 : thread1 is locked.
2016-9-28 15:12:56 : thread1 doSoming1....
2016-9-28 15:12:56 : thread 2 get lock.
2016-9-28 15:13:01 : thread1 doSoming2....
2016-9-28 15:13:01 : thread1 is finished.
2016-9-28 15:13:01 : thread1 is unloaded.
2016-9-28 15:13:01 : thread2 is locked.
2016-9-28 15:13:01 : thread2 doSoming1....
2016-9-28 15:13:01 : thread 1 end.
2016-9-28 15:13:06 : thread2 doSoming2....
2016-9-28 15:13:06 : thread2 is finished.
2016-9-28 15:13:06 : thread2 is unloaded.
2016-9-28 15:13:06 : thread 2 end.
可以看到,thread1先获得锁,一会thread2也来拿锁,但这个时候thread1已经占用了,所以thread2一直到thread1释放了锁后才拿到锁。
**这段代码说明lockInterruptibly后面来获取锁的线程需要等待前面的锁释放了才能获得锁。**但这里还没有体现出可中断的特点,为此增加一些代码:
thread2.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //1秒后把线程2中断 thread2.interrupt();
在thread2启动后调用一下thread2的中断方法,好吧,先跑一下代码看看结果:
2016-9-28 15:16:46 : thread 1 get lock.
2016-9-28 15:16:46 : thread1 is locked.
2016-9-28 15:16:46 : thread1 doSoming1....
2016-9-28 15:16:46 : thread 2 get lock.
2016-9-28 15:16:47 : thread 2 is interrupted. <--直接就响应了线程中断
2016-9-28 15:16:51 : thread1 doSoming2....
2016-9-28 15:16:51 : thread1 is finished.
2016-9-28 15:16:51 : thread1 is unloaded.
2016-9-28 15:16:51 : thread 1 end.
和前面的代码相比可以发现,thread2正在等待thread1释放锁,但是这时thread2自己中断了,thread2后面的代码则不会再继续执行。
ReadWriteLock
顾名思义就是读写锁,这种读-写锁的应用场景可以这样理解,比如一波数据大部分时候都是提供读取的,而只有比较少量的写操作,那么如果用互斥锁的话就会导致线程间的锁竞争。如果对于读取的时候大家都可以读,一旦要写入的时候就再将某个资源锁住。这样的变化就很好的解决了这个问题,使的读操作可以提高读的性能,又不会影响写的操作。
一个资源可以被多个读者访问,或者被一个写者访问,两者不能同时进行。
这是读写锁的抽象接口,定义一个读锁和一个写锁。
public interface ReadWriteLock { /** * Returns the lock used for reading. * * @return the lock used for reading */ Lock readLock(); /** * Returns the lock used for writing. * * @return the lock used for writing */ Lock writeLock(); }
在JDK里有个ReentrantReadWriteLock实现,就是可重入的读-写锁。ReentrantReadWriteLock可以构造为公平的或者非公平的两种类型。如果在构造时不显式指定则会默认的创建非公平锁。在非公平锁的模式下,线程访问的顺序是不确定的,就是可以闯入;可以由写者降级为读者,但是读者不能升级为写者。
如果是公平锁模式,那么选择权交给等待时间最长的线程,如果一个读线程获得锁,此时一个写线程请求写入锁,那么就不再接收读锁的获取,直到写入操作完成。
简单的代码分析 在ReentrantReadWriteLock里其实维护的是一个sync的锁,只是看起来语义上像是一个读锁和写锁。看一下它的构造函数:
public ReentrantReadWriteLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); readerLock = new ReadLock(this); writerLock = new WriteLock(this); } //读锁的构造函数 protected ReadLock(ReentrantReadWriteLock lock) { sync = lock.sync; } //写锁的构造函数 protected WriteLock(ReentrantReadWriteLock lock) { sync = lock.sync; }
可以看到实际上读/写锁在构造时都是引用的ReentrantReadWriteLock的sync锁对象。而这个Sync类是ReentrantReadWriteLock的一个内部类。总之读/写锁都是通过Sync来完成的。它是如何来协作这两者关系呢?
//读锁的加锁方法 public void lock() { sync.acquireShared(1); } //写锁的加锁方法 public void lock() { sync.acquire(1); }
区别主要是读锁获得的是共享锁,而写锁获取的是独占锁。这里有个点可以提一下,就是ReentrantReadWriteLock为了保证可重入性,共享锁和独占锁都必须支持持有计数和重入数。而ReentrantLock是使用state来存储的,而state只能存一个整形值,为了兼容两个锁的问题,所以将其划分了高16位和低16位分别存共享锁的线程数量或独占锁的线程数量或者重入计数。
其他
写了一大篇感觉要写下去篇幅太长了,还有一些比较有用的锁:
CountDownLatch
就是设置一个同时持有的计数器,而调用者调用CountDownLatch的await方法时如果当前的计数器不为0就会阻塞,调用CountDownLatch的release方法可以减少计数,直到计数为0时调用了await的调用者会解除阻塞。
Semaphone
信号量是一种通过授权许可的形式,比如设置100个许可证,这样就可以同时有100个线程同时持有锁,如果超过这个量后就会返回失败。
感谢阅读此文,希望能帮助到大家,谢谢大家对本站的支持!