Java锁机制

2019/02/17 JAVA

Java锁机制

Java中存在两种锁机制:synchronized和Lock。数据同步需要依赖锁,锁的同步要依赖什么呢?synchronized是在软件层面依赖JVM,而Lock是在硬件层面依赖特殊的CPU指令。

在HotSpot JVM实现中,锁有个专门的名字:对象监视器(monitor)

1.CAS算法

Compare and Swap(比较和交换),CAS操作需要输入两个值,一个旧值和一个新值,比较旧值和内存值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。用CAS来实现原子操作。

2.可重入锁

当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,可以获取成功,则为可重入锁。

3.自旋锁

当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的,不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。

非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。

3.1 不可重入的自旋锁

public class SpinLock {
   private AtomicReference<Thread> cas = new AtomicReference<Thread>();
   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()方法释放该锁。

当线程第一次已经获取到该锁,在锁释放之前又一次想重新获取该锁,第二次就不能成功获取到。

3.2 可重入的自旋锁

为了实现可重入锁,需要引入一个计数器,用来记录获取锁的线程数。

public class ReentrantSpinLock {
   private AtomicReference<Thread> cas = new AtomicReference<Thread>();
   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()) {
         if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
            count--;
         } else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致了。
            cas.compareAndSet(cur, null);
         }
      }
   }
}

4.锁的状态

锁的状态有4种:无锁状态,偏向锁,轻量级锁,重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁了,再升级到重量级锁(但是锁的升级是单向的,只能从低到高升级,不会出现锁的降级)。JDK1.6种默认开启偏向锁和轻量级锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

4.1 偏向锁(Biased Lock)

偏向锁主要是解决无多线程竞争下的锁性能问题,也就是说只能在单线程下起作用。

无多线程竞争下锁存在的问题:现在几乎所有的锁都是可重入的,也就是已经获得锁的线程可以多次锁住/解锁监视对象,在HotSpot设计中,每次加锁/解锁都会涉及到CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用。

偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“监视”这个线程,之后的多次调用则可以避免CAS操作。如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。

如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

两个进程对锁抢占,一旦偏向锁冲突,双方都会升级为轻量级锁。之后两个线程进入锁竞争状态。

偏向锁就是无锁竞争下可重入锁的简单实现。

4.2 轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

4.3 重量级锁

synchronized是通过对象内部的监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层操作系统的(互斥锁)Mutex Lock来实现的。而操作系统实现线程之间的切换需要从用户态切换到核心态,这个成本非常高,状态之间的转换需要相对较长的时间,因此synchronized效率比较低。这种依赖于操作系统Mutex Lock所实现的锁称之为重量级锁。JDK对synchronized做的优化,其核心就是为了减少这种重量级锁的使用。

5.加锁机制

每个锁都关联一个请求计数器和一个占有它的线程,当请求计数器为0时,这个锁可以被认为是unheld的,当一个线程亲求一个unheld的锁时,JVM记录锁的拥有者,并把锁的请求计数加1,如果同一个线程再次请求这个锁时,请求计数器就会增加,当该线程退出synchronized块时,计数器减1,当计数器为0时,锁被释放(这就保证了锁是可重入的,不会发生死锁的情况)。

5.1 偏向锁流程

在锁的对象头中有个ThreadId 字段,这个字段如果是空的,第一次获取锁的时候,就将自身的ThreadId写入到锁的ThreadId字段内,将锁头内的是否偏向锁的状态位置为1。这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致 ,如果一致,则认为当前线程已经获取了锁,因此不需要再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高效率。

6.Synchronized

Synchromized有三种用法:修饰普通方法,修饰静态方法,修饰代码块。

当synchronized作用在方法上时,锁住的是对象实例(this);

当作用在静态方法时,锁住的是对象对应的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁。

monitorenter:

每个对象有一个监视器锁(monitor) 。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

执行monitorexit的线程必须是objecttref所对应的monitor的所有者。

执行指令时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

Synchronized的语义底层是通过一个montior的对象来完成,wait/notify 等方法也依赖于monitor对象,所以只有在同步的块或者方法中才可以调用这些方法,否则会抛出异常。

进入synchronized时,使得本地缓存失效,synchronized块中对共享变量的读取必须从主内存中读取;退出synchronized时,会将进入synchronized块之前和sunchronized块中的写操作刷入到主内存中。

也就是说,线程a对于进入synchronized块之前或在synchronized中对于共享变量的操作,对于后续的持有同一个监视器锁的线程b可见。

7.Lock锁

实现锁的功能,有两个必备元素:

一个是表示锁状态的变量(假设0表示没有线程获取锁,1表示已有线程占有锁),该变量必须声明为voaltile类型;

一个是队列,队列中的节点表示因未能获取锁而阻塞的线程。

为了解决多核处理器下多线程缓存不一致的问题,表示状态的变量必须声明为voaltile类型,并且对表示状态的变量和队列的某些操作要保证原子性和可见性。原子性和可见性的操作主要通过Atomic包中的方法实现。

线程获取锁的过程:

1.读取表示锁状态的变量;

2.如果表示状态的变量的值为0,那么当前线程尝试将变量设置为1(通过CAS操作完成),当多个线程同时将表示状态的变量值由0设置成1时,仅一个线程能成功,其他线程都会失败;

2.1 若成功,表示获取了锁。如果该线程已位于队列中,则将其出列(并将下一个节点变成队列的头节点);如果该线程未入列,则不用对队列进行维护。然后当前线程从lock方法中返回,对共享资源进行访问;

2.2 若失败,则当前线程将自身放入等待锁的队列中并阻塞自身,此时线程一直被阻塞在lock方法中,没有从该方法中返回(被唤醒后仍然在lock方法中,并从下一条语句继续执行,这里又会回到第一步重新开始);

3.如果表示状态的变量的值为1,那么将当前线程放入等待队列中,然后将自身阻塞(被唤醒后仍然在lock方法中,并从下一条语句继续执行,这里又会回到第一步重新开始)。

唤醒并不表示线程能立刻运行,而是表示线程处于就绪状态,仅仅是可运行而已。

线程释放锁的过程:

1.释放锁的线程将状态变量的值从1设置为0,并唤醒等待锁队列中的队首节点,释放锁的线程就从unlock方法中返回,继续执行线程后面的代码;

2.被唤醒的线程可能和未进入队列并且准备获取锁的线程竞争锁,重复获取锁的过程;

可能有多个线程同时竞争去获取锁,但是一次只能有一个线程去释放锁,队列中的节点都需要它的前一个节点将其唤醒,例如有队列A<-B<-C,那么由A释放锁时唤醒B,B释放锁时唤醒C。

参考:

Lock的实现原理

Search

    Table of Contents