Java并发(4)- synchronized与CAS
引言
上一篇文章中我们说过,volatile通过lock指令保证了可见性、有序性以及“部分”原子性。但在大部分并发问题中,都需要保证操作的原子性,volatile并不具有该功能,这时就需要通过其他手段来达到线程安全的目的,在Java编程中,我们可以通过锁、synchronized关键字,以及CAS操作来达到线程安全的目的。
synchronized
在Java的并发编程中,保证线程同步最为程序员所熟悉的就是synchronized关键字,synchronized关键字最为方便的地方是他不需要显示的管理锁的释放,极大减少了编程出错的概率。
在Java1.5及以前的版本中,synchronized并不是同步最好的选择,由于并发时频繁的阻塞和唤醒线程,会浪费许多资源在线程状态的切换上,导致了synchronized的并发效率在某些情况下不如ReentrantLock。在Java1.6的版本中,对synchronized进行了许多优化,极大的提高了synchronized的性能。只要synchronized能满足使用环境,建议使用synchronized而不使用ReentrantLock。
synchronized的三种使用方式
- 修饰实例方法,为当前实例加锁,进入同步方法前要获得当前实例的锁。
- 修饰静态方法,为当前类对象加锁,进入同步方法前要获得当前类对象的锁。
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
这三种使用方式大家应该都很熟悉,有一个要注意的地方是对静态方法的修饰可以和实例方法的修饰同时使用,不会阻塞,因为一个是修饰的Class类,一个是修饰的实例对象。下面的例子可以说明这一点:
public class SynchronizedTest {
public static synchronized void StaticSyncTest() {
for (int i = 0; i < 3; i++) {
System.out.println("StaticSyncTest");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
public synchronized void NonStaticSyncTest() {
for (int i = 0; i < 3; i++) {
System.out.println("NonStaticSyncTest");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedTest synchronizedTest = new SynchronizedTest();
new Thread(new Runnable() {
@Override
public void run() {
SynchronizedTest.StaticSyncTest();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
synchronizedTest.NonStaticSyncTest();
}
}).start();
}
//StaticSyncTest
//NonStaticSyncTest
//StaticSyncTest
//NonStaticSyncTest
//StaticSyncTest
//NonStaticSyncTest
代码中我们开启了两个线程分别锁定静态方法和实例方法,从打印的输出结果中我们可以看到,这两个线程锁定的是不同对象,可以并发执行。
synchronized的底层原理
我们看一段synchronized关键字经过编译后的字节码:
if (null == instance) {
synchronized (DoubleCheck.class) {
if (null == instance) {
instance = new DoubleCheck();
}
}
}
可以看到synchronized关键字在同步代码块前后加入了monitorenter和monitorexit这两个指令。monitorenter指令会获取锁对象,如果获取到了锁对象,就将锁计数器加1,未获取到则会阻塞当前线程。monitorexit指令会释放锁对象,同时将锁计数器减1。
JDK1.6对synchronized的优化
JDK1.6对对synchronized的优化主要体现在引入了“偏向锁”和“轻量级锁”的概念,同时synchronized的锁只可升级,不可降级:
这里我不打算详细讲解每种锁的实现,想了解的可以参照《深入理解Java虚拟机》,只简单说下自己的理解。
偏向锁的思想是指如果一个线程获得了锁,那么就从无锁模式进入偏向模式,这一步是通过CAS操作来做的,进入偏向模式的线程每一次访问这个锁的同步代码块时都不需要再进行同步操作,除非有其他线程访问这个锁。
偏向锁提高的是那些带同步但无竞争的代码的性能,也就是说如果你的同步代码块很长时间都是同一个线程访问,偏向锁就会提高效率,因为他减少了重复获取锁和释放锁产生的性能消耗。如果你的同步代码块会频繁的在多个线程之间访问,可以使用参数-XX:-UseBiasedLocking来禁止偏向锁产生,避免在多个锁状态之间切换。
偏向锁优化了只有一个线程进入同步代码块的情况,当多个线程访问锁时偏向锁就升级为了轻量级锁。
轻量级锁的思想是当多个线程进入同步代码块后,多个线程未发生竞争时一直保持轻量级锁,通过CAS来获取锁。如果发生竞争,首先会采用CAS自旋操作来获取锁,自旋在极短时间内发生,有固定的自旋次数,一旦自旋获取失败,则升级为重量级锁。
轻量级锁优化了多个线程进入同步代码块的情况,多个线程未发生竞争时,可以通过CAS获取锁,减少锁状态切换。当多个线程发生竞争时,不是直接阻塞线程,而是通过CAS自旋来尝试获取锁,减少了阻塞线程的概率,这样就提高了synchronized锁的性能。
synchronized的等待唤醒机制
synchronized的等待唤醒是通过notify/notifyAll和wait三个方法来实现的,这三个方法的执行都必须在同步代码块或同步方法中进行,否则将会报错。
wait方法的作用是使当前执行代码的线程进行等待,notify/notifyAll相同,都是通知等待的代码继续执行,notify只通知任一个正在等待的线程,notifyAll通知所有正在等待的线程。wait方法跟sleep不一样,他会释放当前同步代码块的锁,notify在通知任一等待的线程时不会释放锁,只有在当前同步代码块执行完成之后才会释放锁。下面的代码可以说明这一点:
public static void main(String[] args) throws InterruptedException {
waitThread();
notifyThread();
}
private static Object lockObject = new Object();
private static void waitThread() {
Thread watiThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (lockObject) {
System.out.println(Thread.currentThread().getName() + "wait-before");
try {
TimeUnit.SECONDS.sleep(2);
lockObject.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "after-wait");
}
}
},"waitthread");
watiThread.start();
}
private static void notifyThread() {
Thread watiThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (lockObject) {
System.out.println(Thread.currentThread().getName() + "notify-before");
lockObject.notify();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "after-notify");
}
}
},"notifythread");
watiThread.start();
}
//waitthreadwait-before
//notifythreadnotify-before
//notifythreadafter-notify
//waitthreadafter-wait
代码中notify线程通知之后wait线程并没有马上启动,还需要notity线程执行完同步代码块释放锁之后wait线程才开始执行。
CAS
在synchronized的优化过程中我们看到大量使用了CAS操作,CAS全称Compare And Set(或Compare And Swap),CAS包含三个操作数:内存位置(V)、原值(A)、新值(B)。简单来说CAS操作就是一个虚拟机实现的原子操作,这个原子操作的功能就是将旧值(A)替换为新值(B),如果旧值(A)未被改变,则替换成功,如果旧值(A)已经被改变则替换失败。
可以通过AtomicInteger类的自增代码来说明这个问题,当不使用同步时下面这段代码很多时候不能得到预期值10000,因为noncasi[0]++不是原子操作。
private static void IntegerTest() throws InterruptedException {
final Integer[] noncasi = new Integer[]{ 0 };
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
noncasi[0]++;
}
}
});
thread.start();
}
while (Thread.activeCount() > 2) {
Thread.sleep(10);
}
System.out.println(noncasi[0]);
}
//7889
当使用AtomicInteger的getAndIncrement方法来实现自增之后相当于将casi.getAndIncrement()操作变成了原子操作:
private static void AtomicIntegerTest() throws InterruptedException {
AtomicInteger casi = new AtomicInteger();
casi.set(0);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
casi.getAndIncrement();
}
}
});
thread.start();
}
while (Thread.activeCount() > 2) {
Thread.sleep(10);
}
System.out.println(casi.get());
}
//10000
当然也可以通过synchronized关键字来达到目的,但CAS操作不需要加锁解锁以及切换线程状态,效率更高。
再来看看casi.getAndIncrement()具体做了什么,在JDK1.8之前getAndIncrement是这样实现的(类似incrementAndGet):
private volatile int value;
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
通过compareAndSet将变量自增,如果自增成功则完成操作,如果自增不成功,则自旋进行下一次自增,由于value变量是volatile修饰的,通过volatile的可见性,每次get()都能获取到最新值,这样就保证了自增操作每次自旋一定次数之后一定会成功。
JDK1.8中则直接将getAndAddInt方法直接封装成了原子性的操作,更加方便使用。
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
CAS操作是实现Java并发包的基石,他理解起来比较简单但同时也非常重要。Java并发包就是在CAS操作和volatile基础上建立的,下图中列举了J.U.C包中的部分类支撑图: