典型回答
synchronized是Java内建的同步机制,它提供了互斥的语义和可见性。当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。在Java 5以前,synchronized是仅有的同步手段。在代码中,synchronized可以用来修饰方法,也可以使用在特定的代码块上,本质上synchronized方法等同于把方法全部语句用synchronized块包起来。
ReentrantLock,通常翻译为再入锁,是Java 5提供的锁实现,它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取,代码书写也更加方便。与此同时,ReentrantLock提供了很多实用的方法,能够实现很多synchronized无法做到的细节控制,比如可以控制fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须明确调用unlock()方法释放锁。
synchronized和ReentrantLock的性能不能一概而论,早期版本synchronized在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于ReentrantLock。
知识扩展
1、理解线程安全
线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性。这里的状态反映在程序中其实可以看作是数据。换个角度,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题。由此得到两个保证线程安全的办法:
- 封装:通过封装我们可以将对象内部状态隐藏、保护起来。
- 不可变:还记得本专栏之前讲到的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.println(“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(); } }
下面是在我的电脑上的运行结果:
> java ThreadSafeSample Observer data race, former is 14290, latter is 14292
如果你将两次赋值过程用synchronized保护起来,使用this作为互斥单元,就可以避免别的线程并发地去修改sharedState。
synchronized (this) { int former = sharedState++; int latter = sharedState; }
2、设置公平性
再来看看ReentrantLock。你可能好奇什么是“再入”?它表示当一个线程试图获取一个它已经获取的锁时,这个获取动作就自动成功,这是对锁获取粒度的一个概念。也就是说,锁的持有是以线程为单位,而不是基于调用次数。Jave锁实现强调再入性是为了和pthread的行为进行区分。
再入锁可以设置公平性(fairness),我们可以在创建再入锁时选择是否是公平的。
ReentrantLock fairLock = new ReentrantLock(true);
这里所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”(个别线程长期等待锁,但始终无法获取)情况发生的一个办法。
如果使用synchronized,我们根本无法进行公平性的选择,其永远是不公平的,这也是主流操作系统线程调度的选择。通用场景中,公平性未必有想象中的那么重要,Java默认的调度策略很少会导致“饥饿”发生。与此同时,若要保证公平性则会引入额外开销,自然导致一定的吞吐量下降。所以,建议只有当你的程序确实有公平性需要的时候,才有必要指定它。
ReentrantLock相比synchronized,因为可以像普通对象一样使用,所以可以利用其提供的各种便利方法,进行精细的同步操作,甚至是实现synchronized难以表达的用例,如:
- 带超时的获取锁尝试。
- 可以判断是否有线程,或者某个特定线程,在排队等待获取锁。
- 可以响应中断请求。
- ……
3、条件变量
如果说ReentrantLock是synchronized的替代选择,java.util.concurrent.locks.Condition则是将wait、notify、notifyAll等操作转化为相应的对象,将复杂而晦涩的同步操作转变为直观可控的对象行为。
举例,设计这样一个阻塞的缓冲区,它支持put和take方法。如果试图在空的缓冲区上执行take操作,则在某一个项变得可用之前,线程将一直阻塞;如果试图在满的缓冲区上执行put操作,则在有空间变得可用之前,线程将一直阻塞。我们喜欢在单独的等待set中保存put线程和take线程,这样就可以在缓冲区中的项或空间变得可用时利用最佳规划,一次只通知一个线程。可以使用两个Condition实例来做到这一点。
public class BlockingBuffer { final Lock lock = new ReentrantLock(); final Condition notFull = lock.newCondition(); final Condition notEmpty = lock.newCondition(); final Object[] items = new Object[100]; int putptr, takeptr, count; public void put(Object x) throws InterruptedException { lock.lock(); try { while (count == items.length) notFull.await(); items[putptr] = x; if (++putptr == items.length) putptr = 0; ++count; notEmpty.signal(); } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await(); Object x = items[takeptr]; if (++takeptr == item.length) takeptr = 0; --count; notFull.signal(); } finally { lock.unlock(); } } }
调用await方法将导致当前线程在接到信号或被中断之前一直处于等待状态;而调用signal方法则意味着唤醒一个等待线程。