Java同步锁

Java同步锁

1. Java锁的基本概念

1.1 锁的种类

https://blog.csdn.net/qq_41931837/article/details/82314478

1.1.1 乐观锁和悲观锁

悲观锁认为一定会有别的线程抢占数据,因此操作数据前都要先获取数据的锁,synchronizedlock 都是悲观锁,适用于写操作较多的场景。

乐观锁认为不会有别的线程抢占数据,因此只在写操作前判断是否有其他线程更新了数据,CAS(Compare And Swap)就是乐观锁的一种实现方式,适用于读操作较多的场景。

1.1.2 公平锁和非公平锁

公平锁是多个线程按照申请锁的顺序来获取锁,非公平锁则不规定线程顺序。synchronized是非公平锁,ReentrantLock可以通过指定构造方法的参数来创建公平或非公平锁。

1.1.3 独享锁和共享锁

独享锁即一次只能被一个线程持有,共享锁一次可以被多个线程池有,synchronized和ReentrantLock都是独享锁,ReentrantReadWriteLock的读锁是共享锁,写锁是独享锁。

1.1.4 互斥锁和读写锁

和独享、共享是一个意思。

1.1.5 可重入锁和不可重入锁

1
2
3
4
5
6
7
public sychrnozied void test() {
xxxxxx;
test2();
}
public sychronized void test2() {
yyyyy;
}

Reentrant 就是可重入的意思,所以 Reentrantsynchronized 都是可重入锁,也即上面的代码可以顺利执行,test() 获取了对象锁后,再调用 test2() 时,可以直接用已持有的对象锁直接进入 test2(),如果是不可重入锁则会发生死锁。

1.1.6 自旋锁

自旋锁就是使用循环尝试获取来替代线程阻塞等待,好处是减少线程切换的消耗,但会增加CPU资源消耗。

1.1.7 偏向锁、轻量级锁、重量级锁

这是三种锁的状态,是针对 synchronized 字段优化的,通过对象监视器在内存中的头部字段来标明。

  • 偏向锁是指,如果一段同步代码一直被一个线程访问,则该线程会自动获取锁而不需要申请,以降低获取锁的资源消耗。
  • 轻量锁是指如果锁是偏向锁时,有另一个线程访问了同步代码块,则将偏向锁改为轻量锁。此时另外的线程不会阻塞等待,而是通过自旋来尝试获取锁。
  • 重量级锁则是,如果锁是轻量锁,另一个线程虽然是自旋,但不会一直持续下去,当自旋超过一定次数还没有获取到锁,就会进入阻塞状态,该锁膨胀为重量级锁。

1.1.8 分段锁

分段锁不是一种具体的锁,也不是锁的形式,只是一种设计方案,在ConcurrentHashMap内部使用,通过将数据分段并分别加锁,来提升并发情况下的写入性能。

1.1.9 同步锁

(1)对象锁(方法锁)

1
synchronized(Object object) { ... }

当多个线程使用同一个对象锁则可以达到同步的效果,否则无效。

synchronized 修饰方法时,实际上等同于获取该方法所在的外部类对象的对象锁。

(2)类锁(静态锁)

1
synchronized(DemoClass.class) { ... }

同一个类只有一个类锁,但每个类的实例对象都有自己的对象锁,因此当使用类锁时,所有该类的实例对象拿到的都是同一把锁,而使用对象锁时,不同实例对象之间是无关的。

synchronized 修饰静态方法时,即为静态锁。由于非静态方法会自动持有其所在外部类对象的引用,因此非静态方法加锁等同于其所在外部类实例对象的对象锁,而静态方法不会持有其外部类对象的引用,因此是使用其外部类的字节码对象作为锁。

1.2 锁粗化和锁消除

锁粗化和锁消除的本意都是尽量减少锁的获取和释放,节约资源。

锁消除是虚拟机在编译阶段做逃逸分析时,判断到某段代码,虽然加了锁,但不可能会被其他线程访问到,因此加锁是没必要的,则在编译阶段就去除加锁部分。

锁粗化可以理解为扩展锁的域,例如在一个循环内部对一个操作加锁,由于重复获取和释放锁会带来很大的开销,因此虚拟机在编译阶段,将锁粗化,加载循环体外部。


2. Synchronized关键字

synchronized 只会同步尝试获取同一个 monitor 对象的线程,如果一个线程已经持有该 monitor,则后续所有尝试持有该 monitor 的线程都需要依次等待前面的线程释放 monitor 才能继续执行。

  • 对非静态方法加 synchronized 相当于对象锁。
  • 对静态方法加 synchronized 相等于类锁。
  • 用常量池和自动装箱的基本变量作为监视器,一旦改变了值就相当于改变了监视器。

2.1 Synchronized如何保证原子性

所谓原子性指的是不会被线程调度机制打断的操作。java中对基本数据类型的读和写是原子性操作,但自增自减不是。例如 i++ 在 JVM 中实际上是三步:

  • 取出 i 的值
  • 对值做 +1 操作
  • 重新赋值给 i

虽然其中的每一步都是原子性操作,但这个过程并不是原子性的。所以如果需要保证原子性,可以通过 synchronized 关键字实现:

1
synchronized (this) {i++;}

或者使用原子类 AtomicIntegerAtomicLongAtomicReference 等,其中 AtomicInteger#getAndIncrement() 效果相当于 i++AtomicInteger#getAndDecrement() 效果相当于 i--AtomicInteger#incrementAndGet() 效果相当于 ++i,其内部原理:

1
2
3
4
5
6
7
8
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}

3. Lock

3.1 Lock的基本用法

Lock 的基本用法为:Lock lock = new ReentrantLock();,可以通过 lock.lock() 手动加锁,通过 lock.unlock() 手动释放锁。但 Lock 的性能很差,需要注意在 finally 中释放锁。

1
2
3
4
5
6
7
8
9
Lock lock = new ReentrantLock();
int value = 1;
lock.lock();
try {
value++;
}
finally {
lock.unlock;
}

3.2 Lock的实现原理

https://yq.aliyun.com/articles/640868

Lock 本质还是通过 CAS 乐观锁 实现的。一个线程尝试去获取锁,如果获取到,则更新一个 state 字段,表示当前是正在等待锁还是持有锁还是释放锁的状态,然后去执行操作。否则如果没获取到,则将该线程放到记录等待线程的双向链表中,然后线程做自旋,直到获取到锁。Lock 建议在低锁冲突的情况下用,否则非必要(例如需要公平锁)的情况下,还是使用 synchronized 更好。


4. 基于原生方法手动实现公平锁

https://blog.csdn.net/sddh1988/article/details/68068971

使用队列,在 lock() 内按顺序添加等待的线程名 currentThread()#getName(),然后 peek 出最前的线程名,自旋对比当前线程是否是 peek 出来的线程,如果不是则一直循环。


5. 死锁

死锁产生有四个必要条件:

  • 互斥(线程要求的资源仅能被一个线程所独占)
  • 保持(线程因请求被其他线程独占的资源而阻塞时,保持已获得的资源不释放)
  • 不剥夺(线程独占的资源未使用完之前,不可剥夺该线程对资源的独占)
  • 环路等待(发生死锁时,必然存在线程-资源的环形请求链)。

预防死锁的方式有:

  • 避免运行中请求资源,线程所需的资源一次性分配
  • 一个线程如果请求独占某个资源失败,则其他的资源也拒绝被该线程独占
  • 如果一个线程请求独占某个资源失败,则释放该线程原有的独占资源
  • 给资源进行编号,线程按顺序请求资源,逆序释放资源
  • 超时放弃,尝试获取锁一段时间不成功,则放弃自身独占的资源(如 Lock#tryLock(long time, TimeUnit unit)

6. wait和sleep

https://www.cnblogs.com/loren-Yang/p/7538482.html

  • sleep()Thread 类中的方法,wait()Object 类中的方法,因此所有的对象都能调用 wait()
  • wait() 会释放该对象持有的锁资源,所以一旦一个对象调用了 wait(),其他线程可以通过 notify()notyfyAll() 来唤醒,而 sleep() 不释放锁资源,因此其他线程无法使用同步控制块。

7. Volatile关键字

volatile 一般用在多个线程访问同一个变量时,对该变量进行唯一性约束,volatile 保证了变量的可见性,但不能保证原子性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int i = 1;
boolean flag = false;

Thread write = new Thread(new Runnable() {
public void run() {
i = 2;
flag = true;
}
});
Thread read = new Thread(new Runnable() {
public void run() {
if(flag) {
System.out.println(i);
}
}
});

write.start();
read.start();

上述代码,在某些极端情况下,可能会输出 1,也就是:

  • write 线程先执行了 flag = true
  • 然后 read 线程执行了 if 判断并通过,输出 1
  • 然后 write 线程才执行 i = 2

为了避免这个情况,可以如下改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int i = 1;
volatile boolean flag = false;

Thread write = new Thread(new Runnable() {
public void run() {
i = 2;
flag = true;
}
});
Thread read = new Thread(new Runnable() {
public void run() {
if(flag) {
System.out.println(i);
}
}
});

write.start();
read.start();

volatile 用于告诉 JVM 变量不允许线程缓存以及代码重排序,会使得其所在域的写操作一定发生在读操作之前,且每次有写操作后,都将所在域中的写入值同步到主内存中,从而避免其他线程从缓存中拿到旧数据。