Java锁原理与应用
一、锁
锁是一种互斥的机制,在多线程环境中实现对资源的协调与控制,凡是有资源被多线程共享,涉及到修改的情况就要考虑锁的加持。
(0)Java锁原理
0)引申:Java对象结构
Java对象结构分为3部分:
①对象头(包括:Mark Word(存储了当前对象运行时的状态信息,如HashCode、指向锁记录的指针等)、Class Pointer(指针,指向当前对象类型所在方法区中的Class信息));
如图,MarkWord结构(jdk1.8)
HotSpot 64位操作系统(一个对象的markWord在内存占用8字节)
锁标志位,分别代表无锁、偏向锁、轻量级锁、重量级锁4种状态;
②实例数据;
③对齐填充字节(在内存中占8字节)。
1)在Java中,每个对象都拥有一把锁,存放在对象头中,记录了当前对象被哪个线程占用。
2)操作系统用户态和内核态
由于需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,并发送到网络,CPU划分出两个权限等级,用户态和内核态。所有用户程序都是运行在用户态的,当程序需要做一些内核态的事情,,例如从硬盘读取数据,,或者从键盘获取输入等。而唯一可以做这些事情的就是操作系统,此时程序就需要操作系统请求以程序的名义来执行这些操作,即将用户态程序切换到内核态。
内核态: CPU可以访问内存所有数据,,包括外围设备, 例如硬盘,、网卡, CPU也可以将自己从一个程序切换到另一个程序。
用户态: 只能受限的访问内存,且不允许访问外围设备.,占用CPU的能力被剥夺。
(1)锁的实现方式
0)引申:
在java中,锁的实现主要采用两种方式:1、基于Object的悲观锁;2、基于CAS的乐观锁,Lock接口是基于CAS原理实现。java5之前的版本只有synchronized锁,基于操作系统提供的指令,在内核态实现多线程之间访问资源的同步性;之后发现基于内核态的synchronize的锁开销很大,提出了Lock锁机制,在java5版本中被官方采纳;随后java官方对synchronized进行了优化,提出了对象锁的4种状态概念。在java的后续版本中,两者在性能上差别需要根据实际情况进行选择使用。
1)synchronized
j.u.c.Locks中说明synchronized是在硬件层面依赖特殊的CPU指令。synchronized别编译后会生成monitorenter和monitorexit两个字节码指令,依赖这两个字节码指令进行线程同步。monitor,监视器(管程),一旦线程进入了monitor,那么其他线程只能等待,只有当这个线程退出,其他线程才有机会进入。monitor依赖于操作系统的Mutex Lock实现,所以每当挂起或唤醒线程,都要切换到操作系统的内核态,这个操作比较重量级。在某些情况下,甚至于切换时间本身就会超出线程执行任务的时间。java6开始,对synchronized进行了优化,引入了对象锁的4种状态,分别是无锁、偏向锁、轻量级锁、重量级锁。
eg:
public class SynchronizedDemo { public void method() { synchronized (this) { System.out.println("Method 1 start"); } } }
对以上示例代码执行javap -p -c 指令(说明,javap <---> java class文件分解器, -p <-----> 展示所有的类和成员, -c <-------> 对代码进行反编译, 具体指令说明可通过javap -help 展示)
synchronized特点:
①支持线程可重入;
②等待状态(前一个线程并未释放锁,当前线程处于不断尝试获取锁的状态(对应java中定义的RUNNABLE状态))不可中断;
③synchronized会自动释放锁;
④synchronized是非公平锁;
⑤synchronized既可以锁住代码块,也可以锁住方法;
注:java中定义了线程执行的的6种状态
1.创建 2. 执行 3.销毁 4.时间限制的等待 5.无线等待 6.阻塞
操作系统中定义的线程状态有3种:运行态、阻塞态、就绪态;线程的生命周期在此基础上添加了创建和销毁;
2)Lock接口
Lock接口提供了区别于synchronized的另一种具有方法操作的同步方式,支持更多灵活的结构,可以关联多个Condition(java提供的用户线程通信的接口)对象。
package lock;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class RetreenLockTest {
private static Lock rl = new ReentrantLock();
private static void testReentrantLock(){
Runnable run = ()->{
String name = Thread.currentThread().getName();
//保证lock和unlock不论中间代码是否抛出异常,都正常执行
rl.lock();
try {
System.out.println(name + "获得锁");
}finally {
rl.unlock();
System.out.println(name+"释放锁");
}
};
Thread t1 = new Thread(run);
t1.setName("001");
t1.start();
Thread t2 = new Thread(run);
t2.setName("002");
t2.start();
}
}
特点:
①Lock的lock()方法在等待锁释放过程中是不可中断的,而tryLock()方法是可中断的;
②Lock需要手动释放锁;
③Lock使用读锁可以提高多线程读的效率;
④RetreenLock可以控制是否使用公平锁;
⑤Lock只能锁住代码块;
3)j.u.c.locks包下Lock接口实现类
(2)锁的分类
1)乐观锁/悲观锁
乐观锁认为每次读取数据的时候总是认为没有其他线程进行更新操作,所以不去加锁。但是在更新的时候回去对比一下原来的值,看有没有被更改过。适用于读多写少的场景。乐观锁的本质是CAS。
eg:
(1)mysql中类比version号更新 update xxx set a=aaa where id=xx and version=1
(2)java中的atomic包属于乐观锁实现,即CAS。
悲观锁在每次读取数据的时候都认为其他线程会修改数据,所以读取数据的时候也加锁,这样别人想拿的时候就会阻塞,直到这个线程释放锁,这就影响了并发性能。适合写操作比较多的场景。
eg:
(1) mysql中类比for select xxx for update; update update xx set a = aaa 案例中synchronized实现就是悲观锁(1.6之后优化为锁升级机制),悲观锁书写不当很容易影响性能。
乐观锁和悲观锁往往依靠数据库提供的锁机制实现,数据库锁才能真正保证数据访问的排他性,应用层锁无法保证外部系统不会修改数据。
2)独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有,而共享锁是指该锁可被多个线程所持有。
案例一:ReentrantLock,独享锁,基于AQS(AbstractQueuedSynchronizer),实现了公平锁和非公平锁,ReentrantLock支持可重入(单个线程执行时重新进入同一个子程序仍然是线程安全的,即一个线程可以不用释放锁而重复获取一个锁多次,只是在释放的时候也需要响应释放多次)。
ReentrantLock锁用法示例:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class PrivateLock { Lock lock = new ReentrantLock(); long start = System.currentTimeMillis(); void read() { lock.lock(); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }finally { lock.unlock(); } System.out.println("read time = "+(System.currentTimeMillis() ‐ start)); } public static void main(String[] args) { final PrivateLock lock = new PrivateLock(); for (int i = 0; i < 10; i++) { new Thread(new Runnable() { public void run() { lock.read(); } }).start(); } } }
结果分析:每个线程结束的时间点逐个上升,锁被独享,一个用完下一个,依次获取锁
案例二:ReadWriteLock,read共享,write独享
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class SharedLock { ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); Lock lock = readWriteLock.readLock(); long start = System.currentTimeMillis(); void read() { lock.lock(); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }finally { lock.unlock(); } System.out.println("end time = "+(System.currentTimeMillis() ‐ start)); } public static void main(String[] args) { final SharedLock lock = new SharedLock(); for (int i = 0; i < 10; i++) { new Thread(new Runnable() { public void run() { lock.read(); } }).start(); } } }
结果分析:每个线程独自跑,各在100ms左右,证明是共享的
案例三:同样是上例,换成writeLock
Lock lock = readWriteLock.writeLock();
小节:
- 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
- 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
从RetreenLock源码分析可重入性
加锁过程源码如下,会保存当前执行线程对象到AOS(AbstractOwnableSynchronizer)类的的全局变量中
final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }
protected final void setExclusiveOwnerThread(Thread thread) { exclusiveOwnerThread = thread; }
tryAcquire()方法尝试获取锁的过程验证当前执行线程和全局变量汇总保存的线程是否是同一个,若相同无需通过CAS自旋获取锁,直接进行后续逻辑执行,相当于获得通行证绕过等待释放锁以及获取锁的过程。
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
3)分段锁
ConcurrentHashMap线程安全的主要原理,CHM中维护了一个segment数组,数组中的每个元素是HashEntry数组;segment继承ReentrantLock,每个segment对象就是一把锁,一个segment对象内部存在一个HashEntry数组,即HashEntry数组中的数据同步依赖同一把锁,不同的HashEntry数组的读写互不干扰,就形成了分段锁。
4)可重入锁
可重入锁指的获取到锁后,如果同步块内需要再次获取同一把锁的时候,直接放行,而不是等待。其意义在于防止死锁。 实现原理实现是通过为每个锁关联一个请求计数器和一个占有它的线程。如果同一个线程再次请求这个锁,计数器将递增,线程退出同步块,计数器值将递减。直到计数器为0锁被释放。 场景见于父类和子类的锁的重入(调super方法),以及多个加锁方法的嵌套调用。
案例一:父子可重入
public class ParentLock { byte[] lock = new byte[0]; public void f1(){ synchronized (lock){ System.out.println("f1 from parent"); } } } public class SonLock extends ParentLock { public void f1() { synchronized (super.lock){ super.f1(); System.out.println("f1 from son"); } } public static void main(String[] args) { SonLock lock = new SonLock(); lock.f1(); } }
案例二:内嵌方法可重入
public class NestedLock { public synchronized void f1(){ System.out.println("f1"); } public synchronized void f2(){ f1(); System.out.println("f2"); } public static void main(String[] args) { NestedLock lock = new NestedLock(); //可以正常打印 f1,f2 lock.f2(); } }
5)公平锁/非公平锁
基本概念:常见于AQS,公平锁就是在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,直到按照FIFO的规则从队列中取到自己。 非公平锁与公平锁基本类似,只是在放入队列前先判断当前锁是否被线程持有。如果锁空闲,那么他可以直接抢占,而不需要判断当前队列中是否有等待线程。只有锁被占用的话,才会进入排队。
优缺点:公平锁的优点是等待锁的线程不会饿死,进入队列规规矩矩的排队,迟早会轮到。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。 非公平锁的性能要高于公平锁,因为线程有几率不阻塞直接获得锁。ReentrantLock默认使用非公平锁就是基于性能考量。但是非公平锁的缺点是可能引发队列中的线程始终拿不到锁,一直排队被饿死。
编码方式:ReentrantLock支持创建公平锁和非公平锁(默认),想要实现公平锁,使用new ReentrantLock(true)。AQS中有一个state标识锁的占用情况,一个队列存储等待线程。 state=0表示锁空闲。如果是公平锁,那就看看队列有没有线程在等,有的话不参与竞争,追加到尾部。如果是非公平锁,那就直接参与竞争,不管队列有没有等待者。 state>0表示有线程占着锁,这时候无论公平与非公平,都直接去排队。
备注: 因为ReentrantLock是可以定义公平、非公平锁次数。所以state>0而不是简单的0和1,而synchronized只能是非公平锁
6)CountDownLatch
允许一条或多条线程等待其他线程中的一组操作完成后,再继续执行;
如图所示:CountDownLatch调用次序
7)锁升级
java中每个对象都可作为锁,锁有四种级别,按照量级从轻到重分为:无锁、偏向锁、轻量级锁、重量级锁。设计4种锁的目的是线程尽量在操作系统的用户空间完成锁的的获取与释放,一旦进入重量级锁状态,将会调用内核空间,产生较大的开销。
- 偏向锁(一个对象被加锁,但是在实际运行过程中,只有一个线程会获取这个对象锁,那么,此时最好的方式就是不经过系统状态切换,在用户态就完成任务,即对象的mark word标记中需要记录线程id);
- 轻量锁(两个线程需要获取对象锁的情况,线程通过CAS机制尝试获取锁,一旦获得,线程和对象锁绑定,并且互相知道对方的存在);
- 重量级锁(自旋等待的线程超过一个,轻量级锁就升级为重量级锁,对象锁的状态被标记为重量级锁,需要通过monitor来对线程进行控制,此时使用同步原语来锁定资源,对线程的控制最为严格)就是围绕如何使得cpu的占用更划算而展开的。
在操作系统中,阻塞就要存储当前线程状态,唤醒就要再恢复,这个过程是要消耗时间的。如果A使用锁的时间远远小于B被阻塞和挂起的执行时间,那么我们将B挂起阻塞就相当的不合算,于是出现自旋,自旋指的是锁已经被其他线程占用时,当前线程不会被挂起,而是在不停的试图获取锁(可以理解为不停的循环),每循环一次表示一次自旋过程。显然这种操作会消耗CPU时间,但是相比线程下文切换时间要少的时候,自旋划算。 如果自旋的线程过多,再上重量级锁阻塞和挂起。
举个例子,假设公司只有一个会议室(共享资源)
- 偏向锁: 前期公司只有1个团队,那么什么时候开会都能满足,就不需要预约,OA里直接默认设定为使用者A。A在会议室门口挂了 个牌子,写着A专用。
- 轻量级锁: 随着业务发展,扩充为2个团队,于是当AB同时需要开会时,两者在OA抢占。偏向锁升级为轻量级锁,但是未抢到者在门口会不停敲门询问(自旋,循环)。
- 重量级锁: 后来随着团队规模继续扩充,发现这种不停敲门的方式很烦,BCDEF……都在门口站着一直问。于是锁再次升级。 如果会议室被A占用,那么其他团队直接等着(wait进入阻塞),直到A用完。
注意点:
- 上面几种锁都是JVM自己内部实现,我们不需要干预,但是可以配置jvm参数开启/关闭自旋锁、偏向锁。
- 锁可以升级,但是不能反向降级:偏向锁→轻量级锁→重量级锁
- 无锁争用的时候使用偏向锁,第二个线程到了升级为轻量级锁进行竞争,更多线程时,进入重量级锁阻塞
8)互斥锁/读写锁
- 典型的互斥锁:synchronized,ReentrantLock,读写锁:ReadWriteLock 前面都用过了;
- 互斥锁属于独享锁,读写锁里的写锁属于独享锁,而读锁属于共享锁。
二、AQS
1)概念
AbstractQuenedSynchronizer 抽象的队列式同步器,是一个抽象类,这个类在 java.util.concurrent.locks包。AQS是对CAS的进一步封装和丰富,引入了独占锁、共享锁等性质,是除了java自带的synchronized关键字之外的锁机制。它是实现同步器的基础组件,并发包中锁的底层使用AQS实现。
AQS通过state记录上锁状态,所有线程共享该资源,谁先修改成功谁就持有锁。修改失败的就被交给AQS进行丢到FIFo同步队列排队等候,直到线程释放锁,唤醒自己,重新去修改state。
2)AQS上锁、解锁过程
上锁:state的值是否为0,为0的话可以抢锁,此时就调用CAS的方法(AQS中的compareAndSetSate方法)去原子性修改其值,返回true,上锁成功。
排队获取锁:假如此时A线程上锁成功了,A还没有释放锁。B线程来的时候,判断state是否为0,若不是,AQS就开始将其包装成一个Node节点,如果是第一个来排队的线程(这时会有一个自旋),判断队列为空,new 一个空Node作为头节点和尾节点(head=tail=new Node())。然后在第二次循环的时候,将新的节点的前继节点node.pre = tail, tail.next = node,cas修改尾节点为新节点compareAndSetTail(tail, node),成功加入尾部节点。将进来排队包装成节点并且放进队尾之后,下一步就是自旋循环状态,再一次判断自己是否符合抢锁条件(node.pre==head),如果不符合就进入park,将线程waiting,等待前面持有锁的线程释放锁。
解锁:tryRelease() 方法返回true的时候,AQS开始拿到当前持锁的node=head节点,将其head.next =null方便gc。判断当前节点不为空,状态不为0时(释放锁的时候cas将其状态改为0),开始唤醒下一个节点。找到正常状态的节点之后,执行LockSupport.unpark(s.thread);,唤醒下一个要拿锁的节点,唤醒之后继续执行acquireQueued方法中的自旋。
三、原子操作(atomic)
1)概念
原子操作(atomic operation)意为"不可被中断的一个或一系列操作" 。类似于数据库事务,redis的multi。
2)CAS
Compare And Swap,即比较并替换,CAS操作包含三个操作数—内存位置(addr)、预期原值(oldValue)、新值(newValue)。通过源码理解cas的原理,对象接收到线程进行修改的操作,判断对象值是否和线程提供的oldValue相同,若一致,则替换为newValue;若不一致,则代表已经修改过,返回状态0。
计数器问题发生归根结底是取值和运算后的赋值中间,发生了插队现象。
eg,cas应用:
import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private static AtomicInteger i = new AtomicInteger(0); public int get(){ return i.get(); } public void inc(){ try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } i.incrementAndGet(); } public static void main(String[] args) throws InterruptedException { final AtomicCounter counter = new AtomicCounter(); for (int i = 0; i < 10; i++) { new Thread(new Runnable() { public void run() { counter.inc(); } }).start(); } Thread.sleep(3000); //同样可以正确输出10 System.out.println(counter.i.get()); } }
注:AtomicInteger是基于unsafe类cas思想实现(unsafe类提供了硬件级别的原子操作,在sun.misc包下,不属于Java标准。unsafe类中方式都是native方法,很多Java的基础类库,包括一些高性能开发库都是基于Unsafe类开发,比如 Netty、Hadoop、Kafka 等。java不能直接访问操作系统底层,而是通过本地方法来访问)。
3)cas原理分析
cas函数原理:
上图所示代码如果要确保线程安全的前提是cas的操作必须是原子性的,cpu对cas原子操作提供了指令,如x86架构下的cpu,通过cmpxchg指令支持cas,上层只需要调用即可。
4)atomic
上面展示了AtomicInteger,关于atomic包,还有很多其他类型:
基本类型
AtomicBoolean:以原子更新的方式更新boolean;
AtomicInteger:以原子更新的方式更新Integer;
AtomicLong:以原子更新的方式更新Long;
引用类型
AtomicReference : 原子更新引用类型
AtomicReferenceFieldUpdater :原子更新引用类型的字段
AtomicMarkableReference : 原子更新带有标志位的引用类型;
数组
AtomicIntegerArray:原子更新整型数组里的元素。
AtomicLongArray:原子更新长整型数组里的元素。
AtomicReferenceArray:原子更新引用类型数组里的元素。
字段
AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
AtomicLongFieldUpdater:原子更新长整型字段的更新器。
AtomicStampedReference:原子更新带有版本号的引用类型。
注意:使用atomic要注意原子性的边界,把握不好会起不到应有的效果,原子性被破坏。
案例:隔离失败了!
import java.util.HashMap; import java.util.Map; public class BadLocal{ public static void main(String[] args) { ThreadLocal<Map> local = new ThreadLocal(); Map map = new HashMap(); new Thread(()‐>{ //在线程设置后,过段时间取name //猜一猜结果? map.put("name","i am "+Thread.currentThread().getName()); local.set(map); System.out.println(Thread.currentThread().getName()+":" +local.get().get("name")); //do something... try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+":" +local.get().get("name")); }).start(); new Thread(()‐>{ //在线程中赋值name map.put("name","i am "+Thread.currentThread().getName()); local.set(map); }).start(); } }
感谢阅读,借鉴了不少大佬资料,如需转载,请注明出处,谢谢!https://www.cnblogs.com/huyangshu-fs/p/14296132.html