聊聊Java中的锁

Posted chenchenxiaobao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊Java中的锁相关的知识,希望对你有一定的参考价值。

乐观锁与悲观锁

乐观锁
乐观锁就是当一个线程A去修改共享数据B时,线程A假设其它线程都不会去修改B,因此线程A在对共享数据B修改时,不会对共享数据B进行加锁,而是线程A在修改时只需要对共享数据B的旧值或数据版本进行校验,如果校验成功,则修改之,如果校验不成功,则修改失败。
 
具体在我们Java编程中比较常见的有利用数据库的version(版本号)字段的自增操作来实现乐观锁,具体的细节我们这里不作介绍。
这里我们还介绍另外一个在Java中的具体实现,那就是比较并更新原子性操作。比如说AtomicInter.compareAndSet()方法的源码如下:
public final boolean compareAndSet(int expect, int update) {
  return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
 
我们可以看到这个方法有两个参数:expect与update,这个方法的含义就是:如果当前AtomicInteger对象中的值为expect则将它更新为update。从代码中可以看到它的底层实现是利用unsafe.compareAndSwapInt()方法来实现的,unsafe类中compareAndSwapInt()是native方法,最终会利用现代CPU的比较并交换原子指令来完成,这里我不作展开,有兴趣读者可以自行去研究。
 
我们再可以再对unsafe.compareAndSwapInt(this, valueOffset, expect, update)这个方法再稍稍展开介绍一下,请看如下AtomicInter类的一段代码:
private static final long valueOffset;
static {
  try {
    valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
  } catch (Exception ex) {
    throw new Error(ex);
  }
}
 
private volatile int value;
 
那么,valueOffset的含义是啥呢?
在Java中每个对象在内存中都会拥有一个起始地址,则这个valueOffset就是代表某一个字段相对于对象起始地址的偏移量,那么这样的话,我们就很容易理解unsafe.objectFieldOffset()这个方法的含义了,这个方法的含义就是获取value属性相对当前AtomicIntger对象的相对偏移量。
因此,unsafe.compareAndSwapInt(this, valueOffset, expect, update)这个方法的this参数就是指对象(可以知道对象起始地址),valueOffset相对于对象的偏移量,expect是预期值,update是新值。然后就可以完成比较并交换的原子性操作了。
 
说明:关于这样的原子性操作,后面的章节中会涉及到很多,这里讲了,后面就不再赘述。
 
1.1.2 悲观锁
悲观锁就是当一个线程A去修改共享数据B时,假设其它线程都可能要去修改共享数据B,为了保证自己的修改不被丢失,则线程A在修改共享数据B之前要进行加锁操作以阻塞其它线程,走到线程A自己修改完成再释放锁。
 
在Java中,悲观锁最为常见的实现方式有synchronized关键字与ReentrantLock类。
这里我们可以对synchronized的加锁原理做一个简单的阐述:在JVM中,synchronized通过进入和退出Monitor对象来实现对方法或代码块的同步,尽管实现细节上,方法同步与代码块同步有所区别,但都可以通过monitorenter和monitorexit指令来实现。每一个Java对象都会关联一个Monitor对象,synchronized所使用的锁都存储于Java对象头中,我们这里不展开Java对象头的定义细节。
 
对于synchronized关键字来说,锁对象的具体情况如下:
(1)、同步静态方法,锁为类的Class对象;
(2)、同步实例方法,锁为类的实例对象;
(3)、同步代码块,锁为synchronized(obj)中的obj对象。
 
因此,对于synchronized关键字来说,要获得锁,那么当前线程必须拥有相同的锁对象。我们以第(3)种情况代码块同步来进行说明,如果线程A获取了锁对象obj,如果其它所有线程都无法获得obj,则其它所有线程都将阻塞在synchronized(obj)之前。
但同时,事实上不同线程也可以获得相同的锁对象,即如果不同线程可以获得相同的锁对象,那么这些线程也可以并发的执行synchronized(obj){}代码块。
因此synchronized关键字作为悲观锁的前提是:线程A获得到锁之后,其它线程无法获得线程A所持有的锁对象。
这也就是有的时候,我们也可以利用synchronized(obj)来实现细粒度锁,关于细粒度锁的详情,这里我们亦不再展开。
 
ReentrantLock的示例代码如下:
public class ReentrantLockTest {
  private static ReentrantLock lock = new ReentrantLock();
 
  public static void main(String [] args) throws Exception {
    Thread t1 = new Thread(new Runnable() {
      @Override
      public void run() {
        hello(1);
      }
    });
    Thread t2 = new Thread(new Runnable() {
      @Override
      public void run() {
        hello(2);
      }
    });
    t1.start();
    t2.start();
  }
 
  private static void hello(int threadNo) {
    lock.lock();
    try {
      System.out.println(String.format("线程%s打印了hello world,我睡这儿,其它线程也进不来", threadNo));
      Thread.sleep(2000);
      System.out.println("我睡醒了,其它人来吧");
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      lock.unlock();
    }
  }
}
 
这只是一个利用ReentrantLock来演示悲观锁的例子,关于ReentrantLock的实现细节,后面的章节中我们会进行详细的介绍。
 
可重入锁与非可重入锁
可重入锁
可重入锁是指一个线程A获取了锁对象之后,线程A在执行完成之前还可以反复的获得同一把锁。
例如,我们第1.1节中提取的synchronized关键字与ReentrantLockf类都是属于可重入锁。
 
synchronized关键字这个我们都比较清楚,synchronized方法的递归调用中,实例对象的在synchronized方法A中调用同实例对象的synchronized方法B。这都不会有任何问题,皆因为synchronized关键字是可重入锁的一种实现。
 
关于ReentrantLock是如何实现可重入锁的,我们这里不作介绍,在后面介绍AQS的时候,再来介绍ReentrantLock是如何实现可重入锁的。
 
 
非可重入锁
非可重入锁是相对于可重入锁而言的,就是指一个线程A获得了锁之后,线程A在释放锁之后,无法再次获得已经得到的那把锁。这里我们自己来实现一个不可重入的锁,代码如下:
/**
* 自定义不可重入锁(同时也是独占锁、非公平锁,关于独占锁与非公平锁后面会介绍)
* 如果当前线程A获得了锁之后,其它线程B,C,D都可以来等待A竞争锁(等待线程A释放锁)
* 但是线程A自己不可以在释放锁之前再来尝试获取锁,否则将会陷入死锁,一般情况下,我们不建议使用非可重入锁,这里仅仅只是演示一下非可重入锁的实现
*/
public class NonReentrantLock {
 
  private final Sync sync;
 
  static class Sync extends AbstractQueuedSynchronizer {
    @Override
    protected boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
          //此时当前线程尚未得到锁,多个线程可能会在这里竞争锁,所以需要用原子操作的结果来判断。
          if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
          return true;
        }
      }
      return false;
    }
 
    @Override
    protected boolean tryRelease(int releases) {
      if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
      setState(0);
        return true;
    }
 
    @Override
    protected boolean isHeldExclusively() {
      return getExclusiveOwnerThread() == Thread.currentThread();
    }
 
    //加锁
    final void lock() {
      if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
      else
        acquire(1);
    }
 
    //释放锁
    final void unlock() {
      release(1);
    }
  }
 
  public NonReentrantLock() {
    sync = new Sync();
  }
 
  //加锁
  public void lock() {
    sync.lock();
  }
 
  //释放锁
  public void unlock() {
    sync.unlock();
  }
}
 
在这里,内部类Sync继承自AbstractQueuedSynchronizer类,关于该类的实现细节,后面会有专门的章节予以介绍,这里我们暂时不管它。
 
 
独占锁与共享锁
独占锁
独占锁是指同一把锁在同一个时刻只能被同一个线程所持有,独占锁也叫互斥锁,其实也叫悲观锁。上面我们已经介绍过,这里就不再赘述了。
 
 
共享锁
共享锁的意思就是当线程A获得了锁之后,其它线程也可以获得到相同的锁,从而拥有与线程A相同的代码执行权限。在Java中,典型的案例有:CountDownLatch、Semaphore与ReentrantReadWriteLock。
 
关于共享锁实现中的CountDownLatch类,我们将在后面介绍AQS时再做详细的介绍,另外两个实现类Semaphore及ReentrantReadWriteLock,读者有兴趣可以自行去阅读其源码。
 
 
非公平锁与公平锁
非公平锁
非公平锁就是:有一把锁,然后有多个线程去抢,谁抢到就是谁的。如下图所示:
?技术分享图片?
 
在线程A尝试获取锁,线程B、C还在A后面排队等待时,线程D可以来插上一杠子,去跟线程A争抢锁。
 
具体在Java中,我们所熟悉的ReentrantLock类,就可以提供了非公平锁与公平锁两种选择,关于这个细节,后面我们在介绍ReentrantLock的实现原理时会再介绍。
 
 
公平锁
公平锁是相对于非公平锁而言的,就是:有一把锁,所有的线程要得到锁,都必须老老实实的去排队,然后挨个获得锁、释放锁。如下图所示:
 
?技术分享图片?
 
这种情况下,所有的线程都只能去排队,而不可以跟队头节点进行抢锁。
 
 
 
 
自旋锁与适应性自旋锁
自旋锁
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
 
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
 
在Java中,我们所熟悉的AtomicInteger的int incrementAndGet()方法中,我们已经知道它是通过原子性操作来实现自增的,那么它是如何实现的呢,毕竟在并发环境中,比较并交换的操作是有可能会失败的,接下来我们来看看该方法的源代码:
public final int incrementAndGet() {
  return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
 
然后我们再看看Unsafe类中的getAndAddInt()方法,为了易读,我将参数名称改了一下:
public final int getAndAddInt(Object obj, long valueOffSet, int delta) {
  int oldValue;
  do {
    oldValue = this.getIntVolatile(obj, valueOffSet);
  } while(!this.compareAndSwapInt(obj, valueOffSet, oldValue, oldValue + delta));
 
  return oldValue;
}
 
显然,在getAndAddInt()方法中是通过无限循环这种自旋操作来保证compareAndSwapInt()操作一定可以得到成功执行的。
 
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。
 
 
 
适应性自旋锁
针对上面自旋锁的缺点,在Java6以后,引入了适应性自旋锁的概念。
 
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
 
因为自旋锁可能会浪费很多的CPU资源,所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
 

以上是关于聊聊Java中的锁的主要内容,如果未能解决你的问题,请参考以下文章

大厂JAVA核心技能来聊聊Java的锁使用

大厂JAVA核心技能来聊聊Java的锁使用

Java的锁

Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)

Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)

Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)