1、乐观锁 VS 悲观锁
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。
1.1 概念
悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法。Java原子类中的递增操作就通过CAS自旋实现的。
1.2 Java中使用
- 悲观锁:Java中,synchronized关键字和Lock的实现类都是悲观锁。
- 乐观锁:Java原子类中的递增操作就通过CAS自旋实现的。
1.3 Mysql中使用
- 悲观锁:手动提交事务,select ... for update;
要使用悲观锁,我们必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。
//我们可以使用命令设置MySQL为非autocommit模式:
set autocommit=0;
//设置完autocommit后,我们就可以执行我们的正常业务了。具体如下:
//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息,锁定这行数据
select status from t_goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status为2
update t_goods set status=2;
//4.提交事务
commit;/commit work;
- 乐观锁:使用版本号
update tablexxx set name=#name#,version=version+1 where id=#id# and version=#version
id字段要求是主键或者唯一索引,这样的话就是行锁,不然的话就是是锁表,会死人的
1.4 总结
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
2、公平锁 VS 非公平锁
2.1 概念
- 公平锁:公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
- 非公平锁:非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
2.2 Java中使用
对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
3、可重入锁 VS 非可重入锁
3.1 概念
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。可重入锁的一个优点是可一定程度避免死锁。
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
3.2 Java中使用
ReentrantLock和synchronized都是重入锁
3.3 ReentrantLock实现原理
ReentrantLock继承父类AQS,其父类AQS中维护了一个同步状态state来计数重入次数,state初始值为0。
获取锁
当线程尝试获取锁时,可重入锁先尝试获取并更新state值,如果state== 0表示没有其他线程在执行同步代码,则把state置为1,当前线程开始执行。
如果state!= 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行state+1,且当前线程可以再次获取锁。释放锁
释放锁时,可重入锁先获取当前state的值,在当前线程是持有锁的线程的前提下,如果state-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。
4、独享锁/排他锁/互斥锁 VS 共享锁/读写锁
独享锁和共享锁同样是一种概念。
4.1 概念
独享锁:独享锁也叫排他锁、互斥锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。
共享锁:共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
4.2 Java中使用
JDK中的synchronized和JUC中Lock的实现类就是互斥锁;
JDK中的ReentrantReadWriteLock 是共享锁;
4.3 共享锁 ReentrantReadWriteLock
ReentrantReadWriteLock中有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
4.4 mysql使用
mysql InnoDB引擎支持表级锁和行级锁,默认情况下,采用行级锁。
MySQL的锁系统:shared lock和exclusive lock(共享锁和排他锁,也叫读锁和写锁,或者S锁和X锁,即read lock和write lock)。
共享锁【S锁】
又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。
这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。排他锁【X锁】
又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。
这保证了其他事务在T释放A上的锁之前不能再读取和修改A。
5、分段锁
5.1 概念
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7和JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size的时候,也就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
5.2 Java中使用
ConcurrentHashMap
LongAdder
6、CAS
CAS(Compare and Swap 比较并交换)是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值(在CAS的一些特殊情况下将仅返回CAS是否成功,而不提取当前值)。
CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。这其实和乐观锁的冲突检查+数据更新的原理是一样的。
CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:
- ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
JAVA1.5开始引入了CAS,主要代码都放在JUC的atomic包下,其中AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作主要代码都放在JUC的atomic包下循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
Java 8 推出了⼀个新的类,LongAdder,他就是尝试使⽤分段 CAS 以及⾃动分段迁移的⽅式来⼤幅度提升多线程⾼并发执⾏ CAS 操作的性能!
在 LongAdder 的底层实现中,⾸先有⼀个 base 值,刚开始多线程来不停的累加数值,都是对
base 进⾏累加的,⽐如刚开始累加成了 base = 5。接着如果发现并发更新的线程数量过多,就会开始施⾏分段 CAS 的机制,也就是内部会搞⼀个Cell 数组,每个数组是⼀个数值分段。这时,让⼤量的线程分别去对不同 Cell 内部的 value 值进⾏ CAS 累加操作,这样就把 CAS 计算压⼒分散到了不同的 Cell 分段数值中了!
这样就可以⼤幅度的降低多线程并发更新同⼀个数值时出现的⽆限循环的问题,⼤幅度提升了
多线程并发更新数值的性能和效率!⽽且他内部实现了⾃动分段迁移的机制,也就是如果某个 Cell 的 value 执⾏ CAS 失败了,那么就会⾃动去找另外⼀个 Cell 分段内的 value 值进⾏ CAS 操作。
这样也解决了线程空旋转、⾃旋不停等待执⾏ CAS 操作的问题,让⼀个线程过来执⾏ CAS 时可
以尽快的完成这个操作。最后,如果你要从 LongAdder 中获取当前累加的总值,就会把 base 值和所有 Cell 分段数值加起来返回给你。
7、 AQS
AbstractQueuedSynchronizer 抽象队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch…
AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。
state的访问方式有三种:
getState()
setState()
compareAndSetState()
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
-
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
-
- tryAquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
-
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
-
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
-
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其他线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证state是能回到零态的。
再以CountDownLatch为例,任务分为N个子线程去执行,state为初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方式,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
8、ReentrantLock底层实现
ReentrantLock主要利用CAS+AQS队列来实现。
8.1 非公平锁
-
- 先通过CAS更新state,如果更新成功,则说明获取锁(先竞争获取锁)
-
- 如果更新失败,则尝试获取锁
- 2.1 先获取AQS中的state,如果state为0,表示此时没有线程获得锁,接着通过CAS算法,将state设置为1,如果设置成功,则当前线程获取锁,同时将当前线程设置为独占线程exclusiveOwnerThread,返回true结束。
- 2.2 如果state不为0,表示已经有线程获得了锁,然后判断获得锁的线程(独占线程)是否为当前线程,如果是,说明是重入情况,将state增加1,返回true结束;如果不是,则说明获取锁失败,把当前线程挂起,并将其写入队列。
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
8.2 公平锁
-
- 获取AQS中的state,如果state为0,表示此时没有线程获得锁;
- 1.1 判断AQS队列是否为空,如果不是空的,加入队列进行排队;如果是空的,通过CAS算法,将state设置为1,设置成功,则当前线程获取锁,同时将当前线程设置为独占线程exclusiveOwnerThread,返回true结束。
-
- 如果state不为0,表示已经有线程获得了锁,
- 2.1 然后判断获得锁的线程(独占线程)是否为当前线程,如果是,说明是重入情况,将state增加1,返回true结束;如果不是,则说明获取锁失败,把当前线程挂起,并将其写入队列。
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;
}
8.3 释放锁
-
- 先判断获得锁的线程是不是当前线程,如果不是,则抛出异常;
-
- 再判断AQS中state值是不是为1(即下面代码中的c==0),如果不是,说明是可重入锁,更新AQS中state值(减一);如果是,则清空独占线程,然后更新AQS中state值
-
- 释放锁以后,如果AQS中队列头不为空,则唤醒AQS中队列头的线程。
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
//释放当前线程占用的锁
protected final boolean tryRelease(int releases) {
// 计算释放后state值
int c = getState() - releases;
// 如果不是当前线程占用锁,那么抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
// 锁被重入次数为0,表示释放成功
free = true;
// 清空独占线程
setExclusiveOwnerThread(null);
}
// 更新state值
setState(c);
return free;
}
9、synchronized底层实现
synchronized实现同步的基础:Java中的每一个对象都可以作为锁
具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象;
- 对于静态同步方法,锁是当前类的Class对象;
- 对于同步方法块,锁是Synchonized括号里配置的对象;
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
9.1 Monitor 的工作机理
synchronized是基于Monitor来实现同步的。
Class和Object都关联了一个Monitor。
-
- 首先,线程进入同步方法中;
-
- 其次,为了继续执行临界区代码,线程必须获取 Monitor 锁。如果获取锁成功,将成为该监视者对象的拥有者。任一时刻内,监视者对象只属于一个活动线程(The Owner)
-
- 拥有监视者对象的线程可以调用 wait() 进入等待集合(Wait Set),同时释放监视锁,进入等待状态。
-
- 其他线程调用 notify() / notifyAll() 接口唤醒等待集合中的线程,这些等待的线程需要重新获取监视锁后才能执行 wait() 之后的代码。
-
- 最后,同步方法执行完毕了,线程退出临界区,并释放监视锁。
9.1 synchronized具体实现
- 1、同步代码块采用monitorenter、monitorexit指令显式的实现;
- 2、同步方法则使用ACC_SYNCHRONIZED标记符隐式的实现;
9.1.1 monitorenter
每一个对象都有一个monitor,一个monitor只能被一个线程拥有。当一个线程执行到monitorenter指令时会尝试获取相应对象的monitor,获取规则如下:
如果monitor的进入数为0,则该线程可以进入monitor,并将monitor进入数设置为1,该线程即为monitor的拥有者;
如果当前线程已经拥有该monitor,只是重新进入,则进入monitor的进入数加1,所以synchronized关键字实现的锁是可重入的锁;
如果monitor已被其他线程拥有,则当前线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor;
9.1.2 monitorexit
只有拥有相应对象的monitor的线程才能执行monitorexit指令。每执行一次该指令monitor进入数减1,当进入数为0时当前线程释放monitor,此时其他阻塞的线程将可以尝试获取该monitor;
10、synchronized VS ReentrantLock
相同点:
-
- 都是悲观锁
-
- 默认都是非公平锁,但是ReentrantLock可以通过参数构造公平锁
-
- 都是可重入锁
不同点:
- 1.synchronized是在JVM层面实现的,因此系统可以监控锁的释放与否;而ReentrantLock是使用代码实现的,系统无法自动释放锁,需要在代码中的finally子句中显式释放锁lock.unlock()。
-
- 在并发量比较小的情况下,使用synchronized是个不错的选择;但是在并发量比较高的情况下,其性能下降会很严重,此时ReentrantLock是个不错的方案。
-
- 在使用synchronized 代码块时,可以与wait()、notify()、nitifyAll()一起使用,从而进一步实现线程的通信。
其中,wait()方法会释放占有的对象锁,当前线程进入等待池,释放cpu,而其他正在等待的线程即可抢占此锁,获得锁的线程即可运行程序;线程的sleep()方法则表示,当前线程会休眠一段时间,休眠期间,会暂时释放cpu,但并不释放对象锁,也就是说,在休眠期间,其他线程依然无法进入被同步保护的代码内部,当前线程休眠结束时,会重新获得cpu执行权,从而执行被同步保护的代码。
wait()和sleep()最大的不同在于wait()会释放对象锁,而sleep()不会释放对象锁。
notify()方法会唤醒因为调用对象的wait()而处于等待状态的线程,从而使得该线程有机会获取对象锁。调用notify()后,当前线程并不会立即释放锁,而是继续执行当前代码,直到synchronized中的代码全部执行完毕,才会释放对象锁。JVM会在等待的线程中调度一个线程去获得对象锁,执行代码。
需要注意的是,wait()和notify()必须在synchronized代码块中调用。
notifyAll()是唤醒所有等待的线程
- 在使用synchronized 代码块时,可以与wait()、notify()、nitifyAll()一起使用,从而进一步实现线程的通信。
11、锁优化——应用层面
11.1 减小锁的持有时间
避免给整个方法加锁
private synchronized void sync() {
method1();
mutexMethod();
method2();
}
//优化
private void sync() {
method1();
synchronized (mutex) {
mutextMethod();
}
method2();
}
11.2 减小锁的颗粒度
将大对象,拆成小对象,大大增加并行度,降低锁竞争. 如此一来偏向锁,轻量级锁成功率提高.;
比如,ConcurrentHashMap,无论是1.7中对segment加锁,还是1.8中对Node节点加锁,都是减少锁粒度,避免对整个map加锁
11.3 读写锁分离来替换独占锁
用ReadWriteLock将读写的锁分离开来,尤其在读多写少的场合,可以有效提升系统的并发能力。
读-读不互斥:读读之间不阻塞。
读-写互斥:读阻塞写,写也会阻塞读。
写-写互斥:写写阻塞。
11.4 锁分离
在读写锁的思想上做进一步的延伸, 根据不同的功能拆分不同的锁,进行有效的锁分离;
在LinkedBlockingQueue内部,take和put操作本身是隔离的,有若干个元素的时候,一个在queue的头部操作, 一个在queue的尾部操作,因此分别持有一把独立的锁;
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
ArrayBlockingQueue是全部只有一把锁,所以LinkedBlockingQueue同步性能会比ArrayBlockingQueue好。ConcurrentLinkedQueue使用CAS实现,性能最佳。
11.5 锁的粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗,所以我们直接加上一把大的锁,避免锁的不断请求;
public void test1(){
synchronized(lock){
//do something
}
//中间是耗时很小的操作
synchronized(lock){
//do something
}
//优化
public void test1(){
synchronized(lock){
//do something
}
还有一种情况,不要再for循环内部加锁,直接在for循环外面加锁。
jdk中使用实例,StringBuffer内部的append方法每次都会加锁,所以当我们连续使用多个append拼接时,jvm将会锁粗化,在第一次 append() 前至 最后一个 append() 后只加一次锁。
11.6 无锁——使用CAS
如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择
与锁相比,使用CAS操作,由于其非阻塞性,因此不存在死锁问题,同时线程之间的相互影响,也远小于锁的方式。
使用无锁的方案,,可以减少锁竞争以及线程频繁调度带来的系统开销。
在java中,BlockingQueue是基于锁和阻塞实现的线程同步,
ConcurrentLinkedQueue是基于基于CAS实现的线程同步。
12、锁状态
锁状态根据竞争情况从弱到强分别是:无锁->偏向锁->轻量级锁+自旋失败->重量级锁;
锁不能降级只能升级:这是为了提高获得锁和释放锁的效率
锁标记存放在Java对象头的Mark Word中。正是因为对象头有存锁状态变化的信息,所以为锁状态的改变提供了依据,偏向锁,轻量锁,都是通过这个来实现的
12.1 偏向锁
虚拟机的团队根据经验发现,大多数情况下,锁不仅不存在多线程竞争,而且总是有同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁——即无锁竞争的情况下为了减少锁获取的资源开销,引入偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录(Mark Word)里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS同步操作来加锁和解锁,只需简单的测试一下对象头的 “Mark Word” 里是否存储着指向当前线程的偏向锁。
简单的说,就是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步,连CAS操作都不做了。
当有另外要给线程去尝试获取这个锁时,偏向模式宣告结束,后续的操作将升级为轻量级锁。
注意:偏向锁可以提高有同步但无竞争的程序性能,他同样有缺陷:如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。1.6之后的虚拟机默认启用偏向锁,可以使用JVM参数来关闭:-XX:-UseBiasedLocking=false;程序将默认进入轻量级锁状态。
12.2 轻量级锁
是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗,轻量级锁所适应的场景是线程交替执行同步块的情况。
线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的 Mark Word 复制到锁记录中,然后线程尝试使用CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。
如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便会尝试使用自旋来获取锁,这时候,线程不会被挂起,会通过自旋CAS的方式再次尝试获取(也称自旋锁),如果多次尝试均失败,则说明存在激烈的竞争,这个时候就会升级成重量级锁。
如果有2个以上的线程争用同一把锁,那么轻量级锁将会失效,升级到重量级锁。
偏向锁和轻量级锁的重要区别是:偏向锁在第一个线程拿到锁之后,将把线程ID 存储在对象头中,后面的所有操作都不是同步的,相当于无锁。而轻量级锁,每次获取锁的时候还是需要使用CAS来修改对象头的记录,在没有线程竞争的情况下,这个操作是很轻量的,不需要使用操作系统的互斥机制。
轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在多个线程同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁
12.3 重量级锁
重量级锁则是通过操作系统将线程切换到内核态并阻塞来实现的。
重量级锁,是JDK1.6之前,内置锁的实现方式。简单来说,重量级锁就是采用互斥量来控制对互斥资源的访问。在前面的几种锁状态均失效的情况下,最终锁会升级成重量级锁,这意味着并发非常强,同一时刻有大量线程请求临界区,当然最终只能有一个线程获取锁进入临界区,其他的线程则阻塞等待,重量级锁会频繁的进行上下文切换,从而非常影响性能,所以在需要支持高并发的场景下应该避免出现同步。
monitor 监视器锁本质上是依赖操作系统的 Mutex Lock 互斥量 来实现的,我们一般称之为重量级锁。因为 OS 实现线程间的切换需要从用户态转换到核心态,这个转换过程成本较高,耗时相对较长,因此重量级锁的效率会比较低。
轻量级锁和重量级锁的重要区别是: 拿不到“锁”时,是否有线程调度和上下文切换的开销。
12.4 锁升级、降级
通常来说,锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
如果锁升级到重量级之后,拿到锁的某个线程被阻塞了,等待了很久,那么轻量级线程将会一直自旋等待,消耗CPU性能(重量级级线程是一直阻塞,不会消耗cpu)。所以,在升级到重量级锁后,轻易不能降级了,防止轻量级锁自旋消耗CPU。
但其实锁降级是会发生的,当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级,重量级锁降级发生于STW阶段,降级对象为仅仅能被VMThread访问而没有其他JavaThread访问的对象。但是锁升降级效率较低,如果频繁升降级的话对JVM性能会造成影响
参考:
https://www.cnblogs.com/hustzzl/p/9343797.html
https://tech.meituan.com/2018/11/15/java-lock.html