本文已参与「新人创造礼」活动,一同打开创造之路。

⭐ JAVA 系列文章旨在具体解说 JAVA 开发中遇到的经典问题以及相关重要内容,深入研究,并以实战操作的方式融会贯通,让读者更好地把握。

本文已收录于 JAVA 系列专栏: JAVA 欢迎订阅,继续更新。

欢迎注重 点赞 保藏⭐ 留言

代码成果万世基,积沙镇海;愿望永在凌云意,神采飞扬;

前语

软件并发现已成为现代软件开发的基础才能,而 Java 精心设计的高效并发机制,正是构建大规模运用的基础之一。

本篇博文的重点是,synchronized 和 ReentrantLock 有什么区别? 有人说 synchronized 最慢,这话靠谱吗?

常见回答

synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它供应了互斥的语义和可见性,当一个线程现已获取其时锁时,其他妄图获取的线程只能等候或许堵塞在那里。

在 Java 5 曾经,synchronized 是仅有的同步手法,在代码中, synchronized 可以用来修饰方法,也可以运用在特定的代码块儿上,本质上 synchronized 方法等同于把方法悉数句子用 synchronized 块包起来。

ReentrantLock,一般翻译为再入锁,是 Java 5 供应的锁完结,它的语义和 synchronized 根柢相同。再入锁通过代码直接调用 lock() 方法获取,代码书写也更加灵敏。与此同时,ReentrantLock 供应了许多有用的方法,可以完结许多 synchronized 无法做到的细节操控,比方可以操控 fairness,也就是公正性,或许运用界说条件等。可是,编码中也需求留心,必需求明确调用 unlock() 方法开释,不然就会一贯持有该锁。

synchronized 和 ReentrantLock 的功用不能混为一谈,前期版本 synchronized 在许多场景下功用相差较大,在后续版本进行了较多改进,在低竞赛场景中体现或许优于 ReentrantLock。

具体分析

关于并发编程,不同公司或许面试官面试风格也不相同,有个别大厂喜爱一贯诘问你相关机制的扩展或许底层,有的喜爱从有用角度出发,所以你在预备并发编程方面需求必定的耐性。

锁作为并发的基础工具之一,至少需求把握:

  • 了解什么是线程安全。
  • synchronized、ReentrantLock 等机制的根柢运用与案例。

更进一步,你还需求:

  • 把握 synchronized、ReentrantLock 底层完结;了解锁胀大、降级;了解偏斜锁、自旋锁、轻量级锁、重量级锁等概念。
  • 把握并发包中 java.util.concurrent.lock 各种不同完结和案例分析。

实战分析

首先,我们需求了解什么是线程安全。

在 Brain Goetz 等专家撰写的《Java 并发编程实战》(Java Concurrency in Practice)中,线程安满是一个多线程环境下正确性的概念,也就是保证多线程环境下同享的、可批改的情况的正确性,这儿的情况反映在程序中其实可以看作是数据。

换个角度来看,假设情况不是同享的,或许不是可批改的,也就不存在线程安全问题,从而可以推理出保证线程安全的两个方法:

  • 封装:通过封装,我们可以将目标内部情况隐藏、保护起来。
  • 不可变:final 和 immutable 就是这个道理,Java 语言现在还没有真正意义上的原生不可变,可是未来或许会引入。

线程安全需求保证几个根柢特性:

  • 原子性,简单说就是相关操作不会中途被其他线程搅扰,一般通过同步机制完结。
  • 可见性,是一个线程批改了某个同享变量,其情况可以当即被其他线程知晓,一般被解释为将线程本地情况反映到主内存上,volatile 就是担任保证可见性的。
  • 有序性,是保证线程内串行语义,避免指令重排等。

或许有点不流通,那么我们看看下面的代码段,分析一下原子性需求体现在哪里。这个比方通过取两次数值然后进行对比,来模拟两次对同享情况的操作。

你可以编译并执行,可以看到,仅仅是两个线程的低度并发,就非常简单碰到 former 和 latter 不相等的情况。这是因为,在两次取值的进程中,其他线程或许现已批改了 sharedState。

public class ThreadSafeSample {
  public int sharedState;
  public void nonSafeAction() {
      while (sharedState < 100000) {
          int former = sharedState++;
          int latter = sharedState;
          if (former != latter - 1) {
              System.out.printf("Observed data race, former is " +
                      former + ", " + "latter is " + latter);
          }
      }
  }
  public static void main(String[] args) throws InterruptedException {
      ThreadSafeSample sample = new ThreadSafeSample();
      Thread threadA = new Thread(){
          public void run(){
              sample.nonSafeAction();
          }
      };
      Thread threadB = new Thread(){
          public void run(){
              sample.nonSafeAction();
          }
      };
      threadA.start();
      threadB.start();
      threadA.join();
      threadB.join();
  }
}

以下是某次运转成果:

Observed data race, former is 9851, latter is 9853

将两次赋值进程用 synchronized 保护起来,运用 this 作为互斥单元,就可以避免其他线程并发的去批改 sharedState。

synchronized (this) {
  int former = sharedState ++;
  int latter = sharedState;
  // …
}

假设用 javap 反编译,可以看到类似片段,运用 monitorenter/monitorexit 对完结了同步的语义:

11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield    #2                // Field sharedState:I
18: dup_x1
…
56: monitorexit

代码中运用 synchronized 非常便利,假设用来修饰静态方法,其等同于运用下面代码将方法体包含进来:

synchronized (ClassName.class) {}

再来看看 ReentrantLock。你或许猎奇什么是再入?它是表示当一个线程妄图获取一个它现已获取的锁时,这个获取动作就自动成功,这是对锁获取粒度的一个概念,也就是锁的持有是以线程为单位而不是根据调用次数。Java 锁完结强调再入性是为了和 pthread 的行为进行区分。

再入锁可以设置公正性(fairness),我们可在创建再入锁时选择是否是公正的。

ReentrantLock fairLock = new ReentrantLock(true);

这儿所谓的公正性是指在竞赛场景中,当公正性为真时,会倾向于将锁赋予等候时刻最久的线程。公正性是削减线程“饥饿”(个别线程长时间等候锁,但一贯无法获取)情况产生的一个方法。

假设运用 synchronized,我们根柢无法进行公正性的选择,其永远是不公正的,这也是主流操作系统线程调度的选择。通用场景中,公正性未必有幻想中的那么重要,Java 默认的调度战略很少会导致 “饥饿”产生。与此同时,若要保证公正性则会引入额定开支,自然会导致必定的吞吐量下降。所以,我建议只有当你的程序的确有公正性需求的时分,才有必要指定它。

我们再从日常编码的角度学习下再入锁。为保证锁开释,每一个 lock() 动作,我建议都当即对应一个 try-catch-finally,典型的代码结构如下,这是个出色的习气。

ReentrantLock fairLock = new ReentrantLock(true);// 这儿是演示创建公正锁,一般情况不需求。
fairLock.lock();
try {
  // do something
} finally {
   fairLock.unlock();
}

ReentrantLock 比较 synchronized,因为可以像普通目标相同运用,所以可以运用其供应的各种便利方法,进行精细的同步操作,甚至是完结 synchronized 难以表达的用例,如:

  • 带超时的获取锁尝试。
  • 可以判别是否有线程,或许某个特定线程,在排队等候获取锁。
  • 可以呼应中断请求。

这儿我特别想强调条件变量(java.util.concurrent.Condition),假设说 ReentrantLock 是 synchronized 的替代选择,Condition 则是将 wait、notify、notifyAll 等操作转化为相应的目标,将凌乱而不流通的同步操作转变为直观可控的目标行为。

条件变量最为典型的运用场景就是标准类库中的 ArrayBlockingQueue 等。

参阅下面的源码,首先,通过再入锁获取条件变量:


/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
  if (capacity <= 0)
      throw new IllegalArgumentException();
  this.items = new Object[capacity];
  lock = new ReentrantLock(fair);
  notEmpty = lock.newCondition();
  notFull =  lock.newCondition();
}

两个条件变量是从同一再入锁创建出来,然后运用在特定操作中,如下面的 take 方法,判别和等候条件满足:

public E take() throws InterruptedException {
  final ReentrantLock lock = this.lock;
  lock.lockInterruptibly();
  try {
      while (count == 0)
          notEmpty.await();
      return dequeue();
  } finally {
      lock.unlock();
  }
}

当队列为空时,妄图 take 的线程的正确行为应该是等候入队产生,而不是直接回来,这是 BlockingQueue 的语义,运用条件 notEmpty 就可以典雅地完结这一逻辑。

那么,怎样保证入队触发后续 take 操作呢?请看 enqueue 完结:

private void enqueue(E e) {
  // assert lock.isHeldByCurrentThread();
  // assert lock.getHoldCount() == 1;
  // assert items[putIndex] == null;
  final Object[] items = this.items;
  items[putIndex] = e;
  if (++putIndex == items.length) putIndex = 0;
  count++;
  notEmpty.signal(); // 告诉等候的线程,非空条件现已满足
}

通过 signal/await 的组合,完结了条件判别和告诉等候线程,非常顺畅就完结了情况流转。留心,signal 和 await 成对调用非常重要,不然假定只有 await 动作,线程会一贯等候直到被打断(interrupt)。

从功用角度,synchronized 前期的完结比较低效,对比 ReentrantLock,大多数场景功用都相差较大。可是在 Java 6 中对其进行了非常多的改进,可以参阅功用对比,在高竞赛情况下,ReentrantLock 依然有必定优势。我在下一讲进行具体分析,会更有助于了解功用差异产生的内涵原因。在大多数情况下,无需纠结于功用,仍是考虑代码书写结构的便利性、可保护性等。

后记

以上就是Java:synchronized 和 ReentrantLock 有什么区别呢?的所有内容了;

介绍了什么是线程安全,对比和分析了 synchronized 和 ReentrantLock,并针对条件变量等方面结合案例代码进行了介绍。

上篇精讲:【JAVA】对比 Hashtable、HashMap、TreeMap 有什么不同?

我是,等待你的注重;

创造不易,请多多支持;

系列专栏:JAVA