在多线程中,每个线程的执行顺序,是无法预测不可控制的,那么在对数据进行读写的时候便存在由于读写顺序多乱而造成数据混乱错误的可能性。那么如何控制,每个线程对于数据的读写顺序呢?这里就涉及到线程锁。
什么是线程锁?使用锁的目的是什么?先看一个例子。
private void testSimple(){ SimpleRunner runner = new SimpleRunner(); pool.execute(runner); pool.execute(runner); } int account01 =10; int account02 = 0; class SimpleRunner implements Runnable{ @Override public void run() { while(true){//保证两个账户的总额度不变 account01 --; sleep(1000); account02 ++; Console.println("account01:"+account01+" account02:"+account02); } } } private void sleep(int time){ try { Thread.sleep(time); } catch (InterruptedException e) { e.printStackTrace(); } }
调用testSimple()方法开启两个线程执行账户金额转移,运行结果如下:
account01:9 account02:2
account01:9 account02:2
account01:8 account02:4
account01:8 account02:4
account01:6 account02:6
account01:6 account02:6
account01:5 account02:7
account01:4 account02:8
很明显两个账户的金额总和无法保证为10,甚至变多了。之所以发生这种状况一方面是因为++ 和--操作不是原子操作,其次两个变量的修改也没有保证同步进行。由于线程的不确定性则将导致数据严重混乱。下面换一种方式看看如何:
我们修改while循环体,不使用++或者--操作,同时对操作进行加锁:
while(true){//保证两个账户的总额度不变 synchronized ("lock"){//通过synchronized锁把两个变量的修改进行同步 account01 = account01 -1; account02 = account02 +1; Console.println("account01:"+account01+" account02:"+account02); sleep(1000); } }
执行结果如下:
account01:9 account02:1
account01:8 account02:2
account01:7 account02:3
account01:6 account02:4
account01:5 account02:5
现在数据就能够完全正常了。这里涉及到synchronized 锁,其目的就是保证在任意时刻,只允许一个线程进行对临界区资源(被锁着的代码块)的操作。
习惯上喜欢称这种机制为加锁,为了容易理解,可以把这种机制理解为一把钥匙和被锁着的代码块,只有拿到钥匙的线程才能执行被锁住的代码块。而钥匙就是synchronized(“lock”)中的字符串对象"lock",而被锁着的代码块则是{}中的代码。
某个线程如果想要执行代码块中的内容,则必须要拥有钥匙"lock"对象。但“lock”有个特性,同一时刻只允许一个线程拥有(暂时不考虑共享锁)。这样就可以保证所有的线程依次执行被锁着的代码块,避免数据混乱。在这里有一个前提条件,也就是钥匙是对于所有线程可见的,应该设置为全局变量且只有一个实例,否则每一个线程都有一个自己的钥匙,那么就起不到锁的作用了。例如:
while(true){ String lock = new String("lock");//每个线程进入run方法的时候都new一个自己的钥匙 synchronized (lock){ account01 = account01 -1; account02 = account02 +1; Console.println("account01:"+account01+" account02:"+account02); sleep(1000); } }
执行结果如下:
account01:8 account02:2 account01:8 account02:2 account01:6 account02:3 account01:6 account02:3 account01:5 account02:5 account01:4 account02:5
这样便又发生了混乱,每个线程都有自己的钥匙,他们随时都可以操作临界区资源,和没有加锁无任何区别。所以在多线程操作中,锁的使用至关重要!!!
在java中有哪些锁?该如何进行分类呢?
1、共享锁/排它锁
共享锁和排他锁是从同一时刻是否允许多个线程持有该锁的角度来划分。
共享锁允许同一时刻多个线程进入持有锁,访问临界区资源。而排他锁就是通常意义上的锁,同一时刻只允许一个线程访问临界资源。对于共享锁,主要是指对数据库读操作中的读锁,在读写资源的时候如果没有线程持有写锁和请求写锁,则此时允许多个线程持有读锁。
在这里理解共享锁的时候,不是任意时刻都允许多线程持有共享锁的,而是在某些特殊情况下才允许多线程持有共享锁,在某些情况下不允许多个线程持有共享锁,否则,如果没有前提条件任意时刻都允许线程任意持有共享锁,则共享锁的存在无意义的。例如读写锁中的读锁,只有当没有写锁和写锁请求的时候,就可以允许多个线程同时持有读锁。这里的前提条件就是“没有写锁和写锁请求”,而不是任意时刻都允许多线程持有共享读锁。
2、悲观锁/乐观锁
主要用于数据库数据的操作中,而对于线程锁中较为少见。
悲观锁和乐观锁是一种加锁思想。对于乐观锁,在进行数据读取的时候不会加锁,而在进行写入操作的时候会判断一下数据是否被其它线程修改过,如果修改则更新数据,如果没有则继续进行数据写入操作。乐观锁不是系统中自带的锁,而是一种数据读取写入思想。应用场景例如:在向数据库中插入数据的时候,先从数据库中读取记录修改版本标识字段,如果该字段没有发生变化(没有其他线程对数据进行写操作)则执行写入操作,如果发生变化则重新计算数据。
对于悲观锁,无论是进行读操作还是进行写操作都会进行加锁操作。对于悲观锁,如果并发量较大则比较耗费资源,当然保证了数据的安全性。
3、可重入锁/不可重入
这两个概念是从同一个线程在已经持有锁的前提下能否再次持有锁的角度来区分的。
对于可重入锁,如果该线程已经获取到锁且未释放的情况下允许再次获取该锁访问临界区资源。此种情况主要是用在递归调用的情况下和不同的临界区使用相同的锁的情况下。
对于不可重入锁,则不允许同一线程在持有锁的情况下再次获取该锁并访问临界区资源。对于不可重入锁,使用的时候需要小心以免造成死锁。
4、公平锁/非公平锁
这两个概念主要使用线程获取锁的顺序角度来区分的。
对于公平锁,所有等待的线程按照按照请求锁的先后循序分别依次获取锁。
对于非公平锁,等待线程的线程获取锁的顺序和请求的先后不是对应关系。有可能是随机的获取锁,也有可能按照其他策略获取锁,总之不是按照FIFO的顺序获取锁。
在使用ReentrantLock的时候可以通过构造方法主动选择是实现公平锁还是非公平锁。
5、自旋锁/非自旋锁
这两种概念是从线程等待的处理机制来区分的。
自旋锁在进行锁请求等待的时候不进行wait挂起,不释放CPU资源,执行while空循环。直至获取锁访问临界区资源。适用于等待锁时间较短的情景,如果等待时间较长,则会耗费大量的CPU资源。而如果等待时间较短则可以节约大量的线程切换资源。
非自旋锁在进行锁等待的时候会释放CPU资源,可以通多sleep wait 或者CPU中断切换上下文,切换该线程。在线程等待时间较长的情况下可以选择此种实现机制。
除此之外还有一种介于两者之间的锁机制——自适应自旋锁。当线程进行等待的时候先进性自旋等待,在自旋一定时间(次数)之后如果依旧没有持有锁则挂起等待。在jvm中synchronized锁已经使用该机制进行处理锁等待的情况。
在工作中可以根据不同的情况选取合适的锁进行使用。无论使用哪种锁,其目的都是保证程序能够按照要求顺利执行,避免数据混乱情况的发生。
常用锁的使用方法
1、synchronized锁:
对于synchronized锁首先需要明白加锁的底层原理。每一个对象实例在对象头中都会有monitor record列表记录持有该锁的线程,底层通多对该列表的查询来判断是否已经有线程在访问临界区资源。JVM内部细节之一:synchronized关键字及实现细节(轻量级锁Lightweight Locking)
在使用synchronized的时候必须弄清楚谁是“钥匙”,属于全局变量还是线程内局部变量,每个加锁的临界区是使用的哪个“钥匙”对象。必须理清楚加锁线程和“钥匙”对象的关系!!!!
synchronized只可以对方法和方法中的代码块进行加锁,而网上所说的“类锁”并不是对类进行加锁,而是synchronized(XXXX.class)。synchronized是不支持对类、构造方法和静态代码块进行加锁的。
public synchronized void showInfo01(){//这里synchronized锁的是this对象,也即synchronized(this) } public void showInfo02(){ synchronized (this){//这里的this可以替换为任意Object对象。注意是Object对象,基本变量不行。java中字符串是String实例,所以字符串是可以的。 //doSomething } }
2、reentranLock
synchronized加锁机制使基于JVM层面的加锁,而ReentrantLock是基于jdk层面的加锁机制。ReentrantLock根据名称可以看出是可重入锁,其提供的构造方法可以指定公平锁或非公平锁。ReentrantLock使用起来比synchronized更加灵活、方便和高效。
/** * Creates an instance of {@code ReentrantLock} with the * given fairness policy. * * @param fair {@code true} if this lock should use a fair ordering policy */ public ReentrantLock(boolean fair) {//通过true或false来指定公平锁或非公平锁 sync = fair ? new FairSync() : new NonfairSync(); }
下面看一下使用方法:这里使用的是默认非公平锁进行测试。
private void testReentrantLock() { MyRunnerForReentrantLock run = new MyRunnerForReentrantLock(); for (int i = 0; i < 10; i++) {//开启10个线程进行测试
sleep(10);//睡眠10ms保证线程开启的顺序能够按照1-10依次开启 pool.execute(run); } } LockTest lockTest = new LockTest(); class MyRunnerForReentrantLock implements Runnable { @Override public void run() { lockTest.reEnterLock(new AtomicInteger(3));//在run方法中调用reEnterLock()方法测试重入测试 } } class LockTest { ReentrantLock reentrantLock = new ReentrantLock();//使用默认的非公平锁ReentrantLock private void reEnterLock(AtomicInteger time) { reentrantLock.lock();//加锁 Console.println(Thread.currentThread().getName() + "--" + time); try { if (time.get() == 0) { return; } else { time.getAndDecrement(); reEnterLock(time);//这里使用递归来测试重入 } } finally { reentrantLock.unlock();//释放锁。注意这里在finally中释放锁避免加锁代码抛出异常导致锁无法释放造成阻塞 } } }
执行结果如下,注意线程输出的顺序.
pool-1-thread-1--3 pool-1-thread-1--2 pool-1-thread-1--1 pool-1-thread-1--0
pool-1-thread-2--3 pool-1-thread-2--2 pool-1-thread-2--1 pool-1-thread-2--0
pool-1-thread-4--3 pool-1-thread-4--2 pool-1-thread-4--1 pool-1-thread-4--0
pool-1-thread-5--3 pool-1-thread-5--2 pool-1-thread-5--1 pool-1-thread-5--0
pool-1-thread-8--3
......
......
可以看出同一个线程中time变量从3、2、1、0依次循环,说明线程进入了循环体,那么线程确实是允许重入,同一个线程可以多次获取该锁。
但是注意以下线程输出的顺序却不是由1到10.而是 pool-1-thread-1、pool-1-thread-2、pool-1-thread-4、pool-1-thread-5、pool-1-thread-8.这就是因为ReentrantLock使用的非公平锁造成的,使用非公平锁的线程在获取“钥匙”的顺序上和线程开始等待的顺序是没有关系的。我们修改一下使用公平锁测试一下:修改以下代码:
ReentrantLock reentrantLock = new ReentrantLock(true);//使用公平锁ReentrantLock
执行结果如下:
pool-1-thread-1--3 pool-1-thread-1--2 pool-1-thread-1--1 pool-1-thread-1--0 pool-1-thread-2--3 pool-1-thread-2--2 pool-1-thread-2--1 pool-1-thread-2--0 pool-1-thread-3--3 pool-1-thread-3--2 pool-1-thread-3--1 pool-1-thread-3--0 pool-1-thread-4--3 pool-1-thread-4--2 pool-1-thread-4--1 pool-1-thread-4--0 pool-1-thread-5--3 pool-1-thread-5--2 .... ....
可以看出线程的执行顺序按照1、2、3、4的顺序进行输出。
3、读写锁的使用
对于读写锁的请求“钥匙”策略如下:
当写锁操作临界区资源时,其它新过来的线程一律等待,无论是读锁还是写锁。
当读锁操作临界区资源时,如果有读锁请求资源可以立即获取,不用等待;如果有写锁过来请求资源则需要等待读锁释放之后才可获取;如果有写锁在等待,然后又过来的有读锁,则读锁将会等待,写锁将会优先获取临界区资源操作权限,这样可以避免写线程的长期等待。
使用方法如下:
private void testReentrantRWLock() { MyRunnerForReentrantRWLock run = new MyRunnerForReentrantRWLock(); for (int i = 0; i < 10; i++) {//开启10个线程测试
sleep(10);//睡眠10ms保证线程开启的顺序能够按照1-10依次开启
pool.execute(run); } } AtomicInteger num = new AtomicInteger(1);//用来切换读写锁测试方法 ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock(true);//公平读写锁 private class MyRunnerForReentrantRWLock implements Runnable { @Override public void run() { if(num.getAndIncrement() ==3){ lockTest.write();//调用写锁测试 }else{ lockTest.read();//调用读锁测试 } } } public void read() {//使用读锁 rwlock.readLock().lock(); try { Console.println(Thread.currentThread().getName()+"------read");
sleep(2000); } finally { rwlock.readLock().unlock(); } } public void write() {//使用写锁 rwlock.writeLock().lock(); try { sleep(2000);//模拟写操作 Console.println(Thread.currentThread().getName()+"------write"); }finally { rwlock.writeLock().unlock(); } }
执行结果如下:
pool-1-thread-1------read pool-1-thread-2------read //在这里有明显的停顿,大约2s之后下面的直接输出,没有停顿 pool-1-thread-3------write pool-1-thread-4------read pool-1-thread-5------read pool-1-thread-7------read pool-1-thread-10------read pool-1-thread-6------read pool-1-thread-8------read pool-1-thread-9------read
由运行结果执行顺序和时间可以看出,在进行write的时候其它读线程进行了等待操作,然后write释放之后,其它读操作同时操作临界区资源,未发生阻塞等待。
4、自旋锁
自旋锁是在线程等待的时候通过自选while(){}空循环避免了线程挂起切换,减少了线程切换执行的时间。因此在选择使用自旋锁的时候尽量保证加锁代码的执行时间小于等待时间,这样就可以避免自旋锁大量占用CPU空转,同时又免去了非自旋锁线程切换的花销。如果加锁代码块较多,此时自旋锁就哟啊占用太多的CPU进行空转,此时如果发生大量线程请求锁则会大量浪费资源。用户可以根据具体情况来自定义自旋锁的实现,可以实现公平自旋锁和非公平自旋锁。
这里有介绍自定义自旋锁的实现方式:Java锁的种类以及辨析(二):自旋锁的其他种类
文章中介绍的很清楚了,TicketLock
和CLHLock 逻辑比较简单,
这里不再详述,只对MCSLock的实现做一下解读。其中原文中MCSLock的实现unlock()方法中在释放资源解锁下一个等待线程的机制有些问题,已经做出了修改,请注意辨别。
package com.zpj.thread.blogTest.lock; /** * Created by PerkinsZhu on 2017/8/16 18:01. */ import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; public class MCSLock {//这是通过链表实现对线程的控制的。每过来一个新的线程则把它添加到链表上阻塞进行while循环,当前一个线程结束之后,修改下一个线程的开关,开启下个线程持有锁。 public static class MCSNode { volatile MCSNode next; volatile boolean isLocked = true; } private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<MCSNode>();//这里保存的是当前线程的node,要理解ThreadLocal 的工作机制 @SuppressWarnings("unused") private volatile MCSNode queue; private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode.class, "queue"); public void lock() { MCSNode currentNode = new MCSNode();//过来一个新线程创建一个node,同时防止在当前线程的NODE中进行保存。 NODE.set(currentNode);//注意,这里的NODE存储的数据各个线程中是不共享的 MCSNode preNode = UPDATER.getAndSet(this, currentNode);//获取前一个node节点,并更新当前节点 if (preNode != null) {//前一个节点存在说明有线程正在操作临界区资源。则当前线程循环等待 preNode.next = currentNode;//把当前节点加入到链表中,等待获取资源 while (currentNode.isLocked) {}//循环等待,直至前一个线程释放资源,修改当前node的isLocked标志位 } } public void unlock() { MCSNode currentNode = NODE.get();//取出当前线程的node节点 if (currentNode.next == null) {//如果没有新的线程等待持锁 if (UPDATER.compareAndSet(this, currentNode, null)) {//把当前node释放,如果成功则结束,如果失败进入else } else { //设置失败说明突然有线程在请求临界区资源进行等待。此时有新的线程更新了UPDATER数据。
//***********************注意下面的逻辑,已经进行修改 【start】********************************* while (currentNode.next == null) {}//等待新加入的线程把节点加入链表 // 此时currentNode.next != null 这里理应使用锁资源,而不应该直接结束,不然等待的线程无法获取“钥匙”访问临界区资源。所以添加以下两行代码释放锁资源 currentNode.next.isLocked = false;//释放新添加线程的等待 currentNode.next = null;
//********************************** end ****************************** } } else { currentNode.next.isLocked = false;//释放下一个等待锁的线程 currentNode.next = null; } } }
5、信号量实现锁效果
在jdk中,除了以上提供的Lock之外,还有信号量Semaphore也可以实现加锁特性。Semaphore是控制访问临界区资源的线程数量,Semaphore设置一个允许同时操作临界区资源的阈值,如果请求的线程在阈值之内则允许所有线程同时访问临界区资源,如果超出设置的该阈值则挂起等待,直至有线程退出释放之后,才允许新的资源获得操作临界区资源的权利。如果需要把它当做锁使用,则只需要设置该阈值为1,即任意时刻只允许一个线程对临界区资源进行操作即可。虽然不是锁,但却实现了锁的功能——线程互斥串行。
使用示例:
Semaphore semaphore = new Semaphore(1);//同时只允许一个线程可以访问临界区资源 private void testSemaphore(){ for(int i = 0; i<5;i++){//开启5个线程竞争资源 pool.execute(new SemapRunner()); } } class SemapRunner implements Runnable{ @Override public void run() { try { Console.println(Thread.currentThread().getName()+" 请求资源"); semaphore.acquire();//请求资源 Console.println(Thread.currentThread().getName()+" 获取到资源"); sleep(2000); Console.println(Thread.currentThread().getName()+" 释放资源"); semaphore.release();//释放资源 } catch (InterruptedException e) { e.printStackTrace(); } } }
运行结果如下:
pool-1-thread-2 请求资源 pool-1-thread-4 请求资源 pool-1-thread-2 获取到资源 pool-1-thread-5 请求资源 pool-1-thread-1 请求资源 pool-1-thread-3 请求资源 pool-1-thread-2 释放资源 pool-1-thread-4 获取到资源 pool-1-thread-4 释放资源 pool-1-thread-5 获取到资源 pool-1-thread-5 释放资源 pool-1-thread-1 获取到资源 pool-1-thread-1 释放资源 pool-1-thread-3 获取到资源 pool-1-thread-3 释放资源
由结果可以看出,只有当一个线程释放资源之后,才允许一个等待的资源获取到资源,这样便实现了类似加锁的操作。
在进行线程操作的过程中需要根据实际情况选取不同的锁机制来对线程进行控制,以保证数据、执行逻辑的正确!!!无论是使用synchronized锁还是使用jdk提供的锁亦或自定义锁,都要清晰明确使用锁的最终目的是什么,各种锁的特性是什么,使用场景分别是什么?这样才能够在线程中熟练运用各种锁。
=========================================
原文链接:多线程(五) java的线程锁 转载请注明出处!
=========================================
----end