目录
  1. 1. Java锁总结
    1. 1.1. 公平锁 / 非公平锁
      1. 1.1.1. 公平锁
      2. 1.1.2. 非公平锁
    2. 1.2. 可重入锁 / 不可重入锁
      1. 1.2.1. 重入锁
      2. 1.2.2. 非重入锁
      3. 1.2.3. 改造为可重入锁
      4. 1.2.4. ReentrantLock中可重入锁实现
    3. 1.3. 独享锁 / 共享锁
      1. 1.3.1. 独享锁
      2. 1.3.2. 共享锁
    4. 1.4. 互斥锁 / 读写锁
      1. 1.4.1. 互斥锁
      2. 1.4.2. 读写锁
    5. 1.5. 乐观锁 / 悲观锁
      1. 1.5.1. 悲观锁
      2. 1.5.2. 乐观锁
    6. 1.6. 分段锁
    7. 1.7. 偏向锁 / 轻量级锁 / 重量级锁
      1. 1.7.1. 锁的状态
      2. 1.7.2. 偏向锁
      3. 1.7.3. 轻量级锁
      4. 1.7.4. 重量级锁
    8. 1.8. 自旋锁
      1. 1.8.1. 回顾CAS算法
      2. 1.8.2. 什么是自旋锁
      3. 1.8.3. Java如何实现自旋锁
      4. 1.8.4. 自旋锁存在的问题
      5. 1.8.5. 自旋锁的优点
      6. 1.8.6. 可重入的自旋锁和不可重入的自旋锁
      7. 1.8.7. 自旋锁与互斥锁
      8. 1.8.8. 自旋锁总结
    9. 1.9. 分布式锁
    10. 1.10. Java中降低锁竞争思路
10-Java锁分类总结

Java锁总结

下面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计

公平锁 / 非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁

可重入锁 / 不可重入锁

重入锁

锁作为并发共享数据,保证一致性的工具,在JAVA平台有多种实现(如 synchronized(重量级) 和 ReentrantLock(轻量级)等等 ) 。这些已经写好提供的锁为我们开发提供了便利。

广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁

在JAVA环境下 ReentrantLock 和synchronized 都是 可重入锁

1
2
3
4
5
6
7
8
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}

synchronized void setB() throws Exception{
Thread.sleep(1000);
}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁

非重入锁

不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。看到一个经典的讲解,使用自旋锁来模拟一个不可重入锁,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UnreentrantLock {
private AtomicReference owner = new AtomicReference();

public void lock() {
Thread current = Thread.currentThread();
// 这句是很经典的"自旋"语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}

public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}

代码也比较简单,使用原子引用来存放线程,同一线程两次调用lock()方法,如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的,而实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的

改造为可重入锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {

private AtomicReference owner = new AtomicReference();
private int state = 0;

public void lock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
state++;
return;
}
//这句是很经典的"自旋"式语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}

public void unlock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
if (state != 0) {
state--;
} else {
owner.compareAndSet(current, null);
}
}
}
}

在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁

ReentrantLock中可重入锁实现

这里看非公平锁的锁获取方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

在AQS中维护了一个private volatile int state来计数重入次数,避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁

独享锁 / 共享锁

独享锁和共享锁在你去读C.U.T包下的ReeReentrantLock和ReentrantReadWriteLock你就会发现,它俩一个是独享一个是共享锁

独享锁

该锁每一次只能被一个线程所持有

共享锁

  该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占
  另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的
  独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
  对于Synchronized而言,当然是独享锁

互斥锁 / 读写锁

互斥锁

  在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。
  如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被变成就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。 在这种方式下,只有一个线程能够访问被互斥锁保护的资源

读写锁

  相比Java中的锁(Locks in Java)里Lock实现,读写锁更复杂一些。假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁。在没有写操作的时候,两个线程同时读一个资源没有任何问题,所以应该允许多个线程能在同时读取共享资源。但是如果有一个线程想去写这些共享资源,就不应该再有其它线程对该资源进行读或写(译者注:也就是说:读-读能共存,读-写不能共存,写-写不能共存)。这就需要一个读/写锁来解决这个问题。Java5在java.util.concurrent包中已经包含了读写锁。
  读写锁既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的
读写锁有三种状态:

  • 读加锁状态
  • 写加锁状态
  • 不加锁状态

  读写锁在Java中的具体实现就是ReadWriteLock
  一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁
  只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态锁,这也是它可以实现高并发的原因。当其处于写状态锁下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;如果是处于读状态锁下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。所以读写锁非常适合资源的读操作远多于写操作的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class Cache {
static Map<String, Object> map = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();

// 获取一个key对应的value
public static final Object get(String key) {
r.lock();
try {
System.out.println("正在做读的操作,key:" + key + " 开始");
Thread.sleep(100);
Object object = map.get(key);
System.out.println("正在做读的操作,key:" + key + " 结束");
System.out.println("---------------------------------------");
return object;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
r.unlock();
}

return key;
}

// 设置key对应的value,并返回旧有的value
public static final Object put(String key, Object value) {
w.lock();
try {
System.out.println("正在做写的操作,key:" + key + ",value:" + value + "开始.");
Thread.sleep(100);
Object object = map.put(key, value);
System.out.println("正在做写的操作,key:" + key + ",value:" + value + "结束.");
System.out.println("---------------------------------------");
return object;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
w.unlock();
}
return value;
}

// 清空所有的内容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}

public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Cache.put(i + "", i + "");
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Cache.get(i + "");
}
}
}).start();
}
}

乐观锁 / 悲观锁

悲观锁

  总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,如行锁、读锁和写锁等,都是在操作之前加锁,在Java中,传统的关系型数据库里边就用到了很多这种锁机制

乐观锁

  总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的
  version方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。核心SQL

1
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};

  CAS操作方式:即compare and swap 或者 compare and set,涉及到三个操作数,数据所在的内存值,预期值,新值。当需要更新时,判断当前内存值与之前取到的值是否相等,若相等,则用新值更新,若失败则重试,一般情况下是一个自旋操作,即不断的重试

分段锁

  分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作
  并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一
  在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
我们一般有三种方式降低锁的竞争程度:

  1. 减少锁的持有时间
  2. 降低锁的请求频率
  3. 使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。
其实说的简单一点就是:
  容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问
  比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程

偏向锁 / 轻量级锁 / 重量级锁

锁的状态

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

  锁的状态是通过对象监视器在对象头中的字段来表明的
  四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级
  这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时)

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价

轻量级锁

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低

自旋锁

我们知道CAS算法是乐观锁的一种实现方式,CAS算法中又涉及到自旋锁,所以这里给大家讲一下什么是自旋锁

回顾CAS算法

CAS是英文单词Compare and Swap(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  1. 需要读写的内存值 V
  2. 进行比较的值 A
  3. 拟写入的新值 B

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。一般情况下是一个自旋操作,即不断的重试

什么是自旋锁

  自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环
  它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名

Java如何实现自旋锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SpinLock {
private AtomicReference cas = new AtomicReference();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}

lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁

自旋锁存在的问题

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高
  2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在”线程饥饿”问题

自旋锁的优点

  1. 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  2. 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

可重入的自旋锁和不可重入的自旋锁

  文章开始的时候的那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的
  而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的
  为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ReentrantSpinLock {
private AtomicReference cas = new AtomicReference();
private int count;
public void lock() {
Thread current = Thread.currentThread();
// 如果当前线程已经获取到了锁,线程数增加一,然后返回
if (current == cas.get()) {
count++;
return;
}
// 如果没获取到锁,则通过CAS自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
if (count > 0) {
count--;
// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了
} else {
cas.compareAndSet(cur, null);
}
}
}
}

自旋锁与互斥锁

  1. 自旋锁与互斥锁都是为了实现保护资源共享的机制
  2. 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者
  3. 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放

自旋锁总结

  1. 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁
  2. 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)
  3. 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU
  4. 自旋锁本身无法保证公平性,同时也无法保证可重入性
  5. 基于自旋锁,可以实现具备公平性和可重入性质的锁

分布式锁

如果想在不同的jvm中保证数据同步,使用分布式锁技术。有数据库实现、缓存实现、Zookeeper分布式锁

Java中降低锁竞争思路

  在高并发系统开发时,由于涉及到多线程,不可避免的会对一些需要独占处理的资源进行加锁处理。但是加锁的话势必会导致锁竞争,若是锁竞争激烈的话会极大影响并发处理效率。因此在写代码的时候需要注意降低锁竞争,以下是一些思路整理:

  • 缩小锁的范围,无需独占处理的代码一定移到同步代码块之外
  • 减少锁竞争,例如ConcurrentHashMap的锁分段技术,将需同步的范围拆分成几份,分别用锁控制
  • CopyOnWrite思想,将需要加锁处理的资源先复制一份,处理完再替换对象引用,如CopyOnWriteArrayList
  • 使用ThreadLocal,threadlocal内对象只有本线程可以查看或添加,就不存在竞争问题,但需注意业务处理完需remove,否则有内存泄漏风险
  • 使用读写锁,对于读多写少的场景可以使用reentrantReadWriteLock,可多线程并发读,单线程写,但是这个锁在读请求远远大于写请求时容易导致写线程饥饿,无法争抢到锁,可以采用jdk1.8提供的乐观锁stampedLock
  • 使用乐观锁,例如简单的计数可以用Atomic系列原子类,通过自旋+CAS提高效率
  • 无锁化编程,例如netty的设计,每个链接实例化一条pipeline,里面的handler都是new出来的,多线程之间各自有自己的pipeline,无需加锁
  • 利用CAS+volatile降低锁粒度,例如普通生产者消费者模式用linkedBlockingQueue,每次往队列里put数据都需争抢锁,而著名的Disruptor框架采用CAS往队列中放入数据,只有数据达到最大容量了才加锁阻塞,极大提高吞吐量
  • 如果单线程能满足性能要求就没必要用多线程,例如Redis就是单线程模型,可以消除锁竞争
文章作者: Eric Liang
文章链接: https://ericql.github.io/2019/11/12/02-Java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/01-%E5%BA%94%E7%94%A8%E7%AF%87/10-%E9%94%81%E7%9F%A5%E8%AF%86%E7%82%B9%E6%80%BB%E7%BB%93/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Eric Liang
打赏
  • 微信
  • 支付宝