悲观锁(Synchronized)和乐观锁(CAS)

文章目录

    • 悲观锁和乐观锁
    • Synchronized
      • Synchronized使用
      • Synchronized底层原理
      • Java1.6对Synchronized的优化
      • synchronized的等待唤醒机制
    • CAS
      • CAS使用
      • CAS底层原理
      • CAS的缺陷
        • 1.ABA问题
        • 2.循环开销过大
        • 3.只能保证一个共享变量的原子操作
      • concurrent包的实现

悲观锁和乐观锁

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

Synchronized

在Java1.5及以前的版本中,synchronized并不是同步最好的选择,由于并发时频繁的阻塞和唤醒线程,会浪费许多资源在线程状态的切换上,导致了synchronized的并发效率在某些情况下不如ReentrantLock。
例如: 当一个线程访问object的一个synchronized(this)同步代码块时,它就获得了这个object的对象锁。结果,其它线程对该object对象所有同步代码部分的访问都被暂时阻塞。而ReentrantLock可以有多个Condition。
更多关于ReentrantLock和Synchronized

在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。

Java1.6对Synchronized的优化

JDK1.6对对synchronized的优化主要体现在引入了“偏向锁”和“轻量级锁”的概念,同时synchronized的锁只可升级,不可降级
悲观锁(Synchronized)和乐观锁(CAS)_第1张图片

偏向锁:

偏向锁的思想是指如果一个线程获得了锁,那么就从无锁模式进入偏向模式,这一步是通过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)已经被改变则什么都不做。进入一个自旋操作,即不断的重试。

CAS使用

可以通过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底层原理

CAS通过调用JNI的代码实现的。JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。
而compareAndSwapInt就是借助C来调用CPU底层指令实现的。
更多参考:JAVA CAS原理深度分析

CAS的缺陷

1.ABA问题

问题:

因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

解决方法:

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

2.循环开销过大

问题:

前面说过,如果旧值(A)已经被改变,就会进入自旋操作。
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。例如,Unsafe下的getAndAddInt方法会一直循环,知道成功才会返回。

解决方案:

如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3.只能保证一个共享变量的原子操作

问题:

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。

解决方案;

可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

concurrent包的实现

CAS操作是实现Java并发包的基石,他理解起来比较简单但同时也非常重要。Java并发包就是在CAS操作和volatile基础上建立的

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

A线程写volatile变量,随后B线程读这个volatile变量。
A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

首先,声明共享变量为volatile;
然后,使用CAS的原子条件更新来实现线程之间的同步;
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

下图中列举了J.U.C包中的部分类支撑图:
悲观锁(Synchronized)和乐观锁(CAS)_第2张图片

参考链接:Java并发(4)- synchronized与CAS
参考链接:JAVA CAS原理深度分析

你可能感兴趣的:(Java多线程)