Java中的【锁】事

128 阅读21分钟

0-内容

Java中实现了很多锁,每种锁的实现也是为了解决实际问题,主要有以下几个方法:

1、为什么要使用锁?

2、使用锁会带来什么问题?

3、各种锁在Java中的实现;

4、对锁进行优化的设计方法;

1、为什么要使用锁?

从问题出发,操作系统为什么要设计锁?锁用来解决什么问题?

这里就要先看看并发编程带来的问题;

1-1、原子性问题

先来看以下代码,这段代码在【单线程环境】下,累加多少次都会和我们预想的一致,但是在【多线程】环境下,这段代码计算结果也许会和预期的不一样;

private int count;

public void add() {
    count++;
}

当有两个线程同时执行上面的代码时,很可能会出现下图中的情况,预期count的值等于2,很有可能出现为1的情况;

因为count++并不是一个原子操作,对于count++来说,在指令级别是三个操作过程:

  1. 获取count的值;
  2. 对count的值+1;
  3. 将计算结果重新赋值给count;

将上面的代码反编译后的结果,可以看到,count++这个操作在指令级别是三个独立的操作;

这就引出【原子性】问题,在并发编程中,原子性就是希望程序相关操作不会中途被其他线程干扰;

1-2、可见性问题

关于单例模式,有种实现方法叫【延迟初始化】,对象一开始不实例化,而是在首次调用这个实例时,才创建对象,后续就不需要再创建对象,参考下面这段代码: 

public class UnsafeLazyLoad {    

     private static UnsafeLazyLoad singleton;

     public static UnsafeLazyLoad getInstance() {  
          if (singleton == null) {            
             singleton = new UnsafeLazyLoad();        
          }
          return singleton;
     }
}

同样,在【单线程】环境下,上面的代码仅创建一个UnsafeLazyLoad实例,但是在【多线程】情况下,假如多个线程同时执行上面的代码,则会出现一种叫【竞态条件】的现象:

竞态条件:基于某个失效的条件来执行某个计算;

当两个线程同时执行getInstance这段代码,很可能都发现singleton为null,这时两个线程会同时new 两个对象,最后返回的是不同的实例。

1-3、有序性问题

CPU 或 Java内存模型在运行期间,会对代码进行特殊处理,使不同线程对程序的操作顺序可能不同,这会导致程序执行的结果,和我们预期结果不一致,这种现象有个称为【指令重排序】。如下面的代码:

public class PossibleReordering {

    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {        
        Thread thread0 = new Thread(() -> {a = 1; x = b;});
        Thread thread1 = new Thread(() -> {b = 1; y = a;}); 
        thread0.start(); 
        thread1.start();
        thread0.join(); 
        thread1.join();
        System.out.println("x = " + x + ", y = " + y);
    }
}

上面这段代码,预期输出的结果是【x = 0, y = 1】,在【没有同步的】【多线程】环境中,可能因为【指令重排序】输出不可预测的结果,如下图所示:

1-4、关于线程安全

通过上面问题的描述,如果没有很好的【同步机制】,在【多线程】环境下对【共享可变】数据的操作或者对程序的执行顺序,会出现无法预期的情况;上面说的原子性、可见性和有序性问题,都属于【线程安全】的问题,那么什么是【线程安全】呢?

这里引用《Java并发编程实战》这本书对【线程安全】的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式,或者线程如何交替执行,并且主调代码 不需要任何额外的同步或协调操作 ,这个类都能表现出正确的行为,就称这个类是 线程安全。

所以,为了保证线程安全,必须采用同步机制。实现同步最好的方式,就是用锁。

2、锁带来的问题

任何技术都不是银弹,锁可以实现同步保证线程安全,但是锁同样有问题,使用锁究竟会有哪些问题?

2-1、性能问题

当使用锁时,锁保护的临界区代码,每次只能一个线程访问,自然将低程序的可伸缩性,程序正确性与性能之间,要找到合适的平衡,为了解决锁的性能问题,Java同样也提供优化方法。

2-2、资源消耗问题

当一个线程要获得锁后,执行锁保护的代码块,但是执行操作的条件不满足时,CPU会将获取锁的线程释放,这样就带来资源消耗问题,为什么会有资源消耗问题?因为CPU需要记录释放锁的线程ID,线程执行到的位置,这样等线程被唤醒时,可以继续执行;

2-3、活跃性问题

活跃性问题通常包括以下几个方面:

1、死锁

所谓【死锁】,就是多个线程之间,不释放自己占有的资源,又得不到被其他线程获取的资源,线程之间因为相互等待,导致程序无法执行。

死锁的问题并不只出现在Java中,数据库在多个事务操作时,也会出现死锁的情况,而数据库解决死锁是通过事务间等待关系的有向图,判断是否存储死锁,并且选择放弃一个事务来解决。

在接下来的内容里,会介绍Java是如何解决死锁问题。

2、活锁

与死锁不同,活锁是当线程间出现竞争后,主动放弃资源(可以理解为主动谦让),导致程序无法执行的情况。

3、饥饿

就是一个等待很久的线程,因为自身优先级不够高,导致在和其他线程的竞争中,始终获取不到锁,所以就一直【饿着】。

既然使用锁这些问题,不用又无法保证线程安全,Java在多个版本中一直在优化和解决这些问题,接下来我们就看看Java是如何实现各种锁,以及是如何解决上面提到的问题。

3、锁在Java中的实现

不同的锁解决不同问题,先来看看各种锁使用的场景:

3-1、乐观锁和悲观锁

3-1-1、悲观锁

1、概念

对同一数据的并发操作,一定会发生修改;

悲观的认为,不加锁一定会发生问题;

2、悲观锁在Java中的实现

在Java中,常用的悲观锁:synchronized 和 ReentrantLock;

  • synchronized在代码中的使用,如代码所示:

对于静态方法,锁的对象就是方法所在的类;

对于非静态方法,锁的对象就是调用方法的实例;

对于代码块,锁的对象就是synchrobized后面括号中的对象;

// 修饰方法
public synchronized void function() {}

// 修饰静态方法
public static synchronized void function() {} 

// 对方法块的修饰
Object obj = new Object();
public void function() {   
    synchronized(obj) { 
    }
}

对于synchronized底层实现,通过反编译java代码,synchronzied是通过【monitorenter】和【monitorexit】两个指令实现,参考以下代码:

private int i;

public void add() {    
    synchronized (this) {        
        i++;    
    }
}

public void add();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter                      // 进入锁
       4: aload_0
       5: dup
       6: getfield      #2                  // Field i:I
       9: iconst_1
      10: iadd
      11: putfield      #2                  // Field i:I
      14: aload_1
      15: monitorexit                       // 释放锁
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit
      22: aload_2
      23: athrow
      24: return

但是synchronized本身有很多问题,java也针对这些问题进行优化;

  • 通过ReentrantLock实现悲观锁:

注意,如果在程序中使用ReentrantLock,一定要在业务逻辑代码中使用try {} finally{} 处理逻辑,并且在finally中调用unlock()方法释放锁;

private final ReentrantLock lock = new ReentrantLock();

public void update() {     
    lock.lock();     
    try {        
        // 业务逻辑处理     
    } finally {         
        lock.unlock();     
    }
}

除了lock()方法以外,ReentrantLock还提供了带有等待时间的tryLock方法,在实现上通过一个随机长度的等待时间,超过时间就释放锁,这就解决之前提到的【活锁】和【死锁】问题;

public boolean tryLock(long timeout, TimeUnit unit)
        throws InterruptedException {
    return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}

3-1-2、乐观锁

1、概念

对同一数据的并发操作,不会发生修改;更新时发现修改过,根据实现方式不同,可以进行报错或者重试;

2、乐观锁在Java中的实现

Java实现乐观锁,就是课上老师提到的【CAS原语】,Java中【sun.misc.Unsafe】这个类中提供的方法,而Unsafe提供的CAS方法底层实现是CPU指令cmpxchg;

相关方法如下,以compareAndSwapInt(Object var1, long var2, int var4, int var5)为例,包括四个参数,分别代表的含义是:

  1. Object obj : 要进行CAS操作值所在的对象;

  2. long var2: 对象中某个属性的内存地址;

  3. int var4 :预期值;

  4. int var5:新值;

    public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

    public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

3、Unsafe提供的CAS方法在Java源代码使用

  • 原子类 - AtomicInteger

AtomicInteger方法的compareAndSet方法,其中valueOffset就是AtomicInteger对象在内存中的地址;

private static final Unsafe unsafe = Unsafe.getUnsafe();

private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
  • ConcurrentHashMap

在ConcurrentHashMap的putVal方法中,根据Key找到元素在Hash表中的位置,使用的就是CAS原语,这也是为什么ConcurrentHashMap性能较好的原因,具体代码以下代码:

4、ABA问题

如果一个变量V初次读取的时候是A值,赋值准备的时候检查期间,值曾经被改成B,后来又被改回A,CAS操作会误认为这个值从没有改变过,也是就是【A-B-A】这样的现象,所以这个漏洞称为CAS操作的【ABA问题】;

解决ABA的问题很简单,就是在更新时使用不是一个引用,而是两个:一个是期望值,另一个是版本号,用【值 + 版本号】的方式更新;

Java从1.5版本,通过引入AtomicStampedReference来解决ABA问题,具体实现参考compareAndSet方法,其中除了【期望的引用】和【当前引用】是否相等,还增加了【预期标志】与【当前标记】是否相等的判断:

public boolean compareAndSet(V expectedReference,
                             V newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
}

同样的问题,在并发更新数据库中的数据时,是否也会出现?

以MySQL为例,默认的隔离级别是可重复读,假如有两个请求在各自的事务中同时去更新一条数据,如果A事务更新成功并提交,后面B事务也更新同样的数据,那么A事务更新的数据就被覆盖掉了,举一个贴近现实的例子:

A和B两个账户同时给账户C转账,A和B转账是两个事务,A给C转账更新账户C的余额,接着B的转账也更新账户C的余额,B的事务就会把之前A的更新给覆盖。

所以通常在数据库对同一个数据更新,会使用【主键+版本号】的方式,避免不同事务间并发修改带来的问题;

通过以上分析,为在并发情况更新数据,提供一种简单的设计方案,通过【值 + 版本号】的方式,可以避免并发环境数据更新带来的覆盖问题;

5、关于同时多个原子数据的问题

保证虽然原子类在性能上比synchronized这样的悲观锁更好,但是一个原子类只能保证自己封装数据的原子性,假如在一个操作中,同时更新两个原子变量,也不能保证两个原子变量操作的线程安全,此时还是需要使用互斥对多个变量进行操作。

3-2、自旋锁和自适应自旋锁

3-2-1、自旋锁

上面提到,互斥同步对性能最大的影响是【阻塞】的实现,因为挂起和恢复线程的操作都需要进入内核态中完成,这些操作给系统并发带来很大压力,对于临界区代码的操作耗时有可能比线程切换带来的耗时更短,Java的设计者认为共享数据的锁定状态只会持续很短的时间,为了很短时间对线程进行挂起和恢复似乎不太值得,如果线程获取不到资源,此时不放弃CPU,而是等待一段时间,看是否能获取资源,于是产生了自旋锁;

关于自旋锁的定义,来自《深入理解Java虚拟机》这本书:

多线程并行执行请求锁时,让后面请求锁的线程稍等一下,线程等待的方式,就是执行一个忙循环,但不放弃处理器执行时间,等待持有锁的线程释放锁,这项技术就是自旋锁。

Java在1.6开始使用【-XX:+UseSpinning】默认开启自旋锁。

自旋锁确实解决了因为线程切换带来的时间消耗问题,但自旋锁这项技术本身也带来了问题:如果锁长时间不被释放,自旋的线程会消耗CPU资源;

所以自旋的时间必须有一定限制,如果超过指定次数获取不到锁,就挂起线程。

Java中使用【-XX:PreBlockSpin】限制自旋次数,默认值为10。

3-2-2、自适应自旋锁

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

3-3、偏向锁、轻量级锁和重量级锁

刚才看到,对于synchronized操作,在并发情况下,Java会将获取不到锁的线程挂起,synchronized代码性能较差的原因,从JDK 6起,为了减少获得锁和释放锁带来的性能消耗,引入了 偏向锁 和 轻量级锁。

偏向锁、轻量级锁 和 重量级锁 在 Java中的实现:

在Java中使用JVM参数【-XX:-UseBiasedLocking】配置来控制是否开启偏向锁,如果关闭,则程序将会使用轻量级锁。

偏向锁的设计,是考虑在大多数情况下,只会由一个线程访问同步代码块,不存在多线程竞争锁的情况,偏向锁就是为了提高只有一个线程访问同步代码块的性能。

而轻量级锁设计的目的,就是为了减少在并发情况下,线程获取不到锁时,通过自旋的方式获取锁,不会阻塞影响性能。

下图内容来自《深入理解Java虚拟机》

3-4、公平锁和非公平锁

1、公平锁

优点:不会出现线程饥饿;

缺点:只有等待队列中第一个线程能被唤醒,其他线程都会被阻塞,因此系统的吞吐量较低;

公平锁解决了上面提到的【饥饿】问题;

2、非公平锁

优点:减少CPU唤起线程的开销,吞吐量较高;

缺点:会出现线程饥饿。

在Java中,synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造函数传入值true来实现公平锁,具体代码如下:

/**
 * 默认创建非公平锁
 */
public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

abstract static class Sync extends AbstractQueuedSynchronizer {}

/**
 * 非公平锁
 */
static final class NonfairSync extends Sync {
}

/**
 * 公平锁
 */
static final class FairSync extends Sync {
}

接下来,我们再看看【公平锁】和【非公平锁】的加锁方式:

左边是【公平锁】,右侧是【非公平锁】

通过源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),

public final boolean hasQueuedPredecessors() {
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}

可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。

3-5、可重入锁和不可重入锁

可重入锁,就是指线程可以重复获得一个由他自己已经持有的锁;上面我们提到的偏向锁也属于可重入锁;

先看下面这段代码,在循环中处理页面逻辑,如果锁是不可重入的,那么线程在第二次循环向获取锁时,线程就会被阻塞,导致代码无法继续;如果一个锁是可重入的,意味着获取锁操作的粒度是【线程】;

public void cycleAdd() { 
    for (int i = 0; i < 1000; i++) { 
        synchronized (this) {            
            // 业务逻辑        
        }
    }
}

在Java中,synchronized本身就是可重入的,关于可重入锁如何设计,《Java并发编程实战》这本书也给出了对于方法:

  • 为每个锁关联一个【获取计数值】和一个【所有者线程】; 
  • 当计数值为0时,这个锁就被认为是没有被任何线程持有; 
  • 当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将【获取计数值】置为1; 
  • 如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减; 
  • 当计数值为0时,这个锁就被释放;

ReentrantLock也是一个可重入锁,它内部的Sync类继承AQS,通过维护一个state记录重入锁【获取计数值】,在AQS内部state用volatile定义,保证了可见性。下面这段代码,就是ReentrantLock如何实现可重入锁的逻辑。

abstract static class Sync extends AbstractQueuedSynchronizer { 

    final boolean nonfairTryAcquire(int acquires) { 
        final Thread current = Thread.currentThread(); 
        int c = getState(); 
        if (c == 0) {
            // 如果状态(计数值)为0,尝试获取锁
            if (compareAndSetState(0, acquires)) {
                // 通过CAS进行比较,表明线程可以获得锁 
                // 设置当前线程为获得锁的线程 
               setExclusiveOwnerThread(current); 
               return true;            
            }
        } else if (current == getExclusiveOwnerThread()) { 
            // 如果获取锁就是线程本身,则状态值+1
            int nextc = c + acquires; 
            if (nextc < 0) // overflow 
                throw new Error("Maximum lock count exceeded"); 
            setState(nextc);
            return true;
        }
        return false;
    }

    protected final boolean tryRelease(int releases) { 
        // 解锁时,对计数值-1 
        int c = getState() - releases; 
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException(); 
        boolean free = false; 
        if (c == 0) { 
            // 当计数值为0时,释放锁,并将原有记录的线程置为null 
            free = true; 
            setExclusiveOwnerThread(null); 
        }
        setState(c);
        return free;
    }
}

对于【不可重入锁】,Java并没有使用显示锁这种方式去实现,而是在线程池ThreadPoolExecutor中的【Worker】类,就被定义为【不可重入锁】。

为什么线程池中的线程不可重入?根据Java源码分析:

We implement a simple non-reentrant mutual exclusion lock rather than use ReentrantLock because we do not want worker tasks to be able to reacquire the lock when they invoke pool control methods like setCorePoolSize.

非重入锁与重入锁的区别就是状态(state),也就是锁计数,只能设定1次,要么是0要么是1。

protected boolean tryAcquire(int unused) { 
     if (compareAndSetState(0, 1)) { 
         // 设置状态成功,当前线程获得,同一个线程向再次获取也无法获得锁
         setExclusiveOwnerThread(Thread.currentThread());
         return true;    
     }
     return false;
}

protected boolean tryRelease(int unused) {
     // 释放锁时直接将线程设为null,锁的计数为0 
     setExclusiveOwnerThread(null);    
     setState(0);    
     return true;
}

3-6、独享锁、共享锁 和 读写锁

上面提到的 synchronized 和 ReentrantLock 都是独享锁,也就是一个锁只能被一个线程持有。

但是在读多写少的场景,如果每次读取共享数据都要加独享锁,每次只能有一个线程访问,这种线性执行非常影响程序【性能】。

所以针对读多写少,Java中的【ReadWriteLock】,确保【多个执行读操作】的线程可以同时访问数据,从而提升性能。

读写锁的多个读线程并不互斥,只有写线程与其他线程互斥。

【ReadWriteLock】只是一个接口类,它的实现类是 ReentrantReadWriteLock,内部定义了两个锁,分别是:

  • 代表读锁的 ReadLock,
  • 代表写锁的 WriteLock。

读锁ReadLock和写锁WriteLock都继承自Lock接口;

同时内部还实现【公平锁】和【非公平锁】,是为了解决读写优先级的问题,并且也是【可重入】的。

public class ReentrantReadWriteLock 
        implements ReadWriteLock, java.io.Serializable {

    private final ReentrantReadWriteLock.ReadLock readerLock;

    private final ReentrantReadWriteLock.WriteLock writerLock;

    final Sync sync;

    public ReentrantReadWriteLock() {
        // 默认使用非公平锁
        this(false);
    }

    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

    public static class ReadLock implements Lock, java.io.Serializable {

        protected ReadLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
        // ...省略具体实现
    }

    public static class WriteLock implements Lock, java.io.Serializable {

        protected WriteLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
        // ...省略具体实现
    }

    // ...具体实现
}

【读写锁】没有单独为读锁和写锁实现加锁逻辑,而是复用通过内部的Sync对象,其中state状态字段,因为是int类型,其中低16位用来表示写状态,而高16位用来记录读状态。有兴趣的同学可以讨论这样实现是否好,并且有没有更好的实现方法。

这是【写锁】加锁的实现:

static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 65535

// 读锁可以理解为共享锁
static int sharedCount(int c)  { 
    return c >>> SHARED_SHIFT; 
}

// 写锁是个排他锁
static int exclusiveCount(int c) { 
    return c & EXCLUSIVE_MASK; 
}

final boolean tryWriteLock() {
    Thread current = Thread.currentThread(); 
    int c = getState(); 
    // 获取当前锁的数量
    if (c != 0) {
        int w = exclusiveCount(c); // 获取写锁的数量 
        if (w == 0 || current != getExclusiveOwnerThread()) 
           // 写锁等于0,但是不是当前线程持有不能加锁 
           return false;
        if (w == MAX_COUNT) // 写锁超过65535, Error 
           throw new Error("Maximum lock count exceeded");
    }    
    if (!compareAndSetState(c, c + 1))        
       return false;    
    setExclusiveOwnerThread(current);    
    return true;
}

这是【读锁】加锁实现

final boolean tryReadLock() {
    Thread current = Thread.currentThread();    
    for (;;) { 
       int c = getState();
       //写锁存在则不能加锁  
       if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) 
          return false;
       int r = sharedCount(c);
       if (r == MAX_COUNT)
          throw new Error("Maximum lock count exceeded"); 
       if (compareAndSetState(c, c + SHARED_UNIT)) {
           if (r == 0) { 
               firstReader = current; 
               firstReaderHoldCount = 1;
           } else if (firstReader == current) { 
               firstReaderHoldCount++;
           } else {                
               HoldCounter rh = cachedHoldCounter;
               if (rh == null || rh.tid != getThreadId(current)) 
                   cachedHoldCounter = rh = readHolds.get(); 
               else if (rh.count == 0) 
                   readHolds.set(rh); 
               h.count++;
           }            
           return true; 
        }    
     }
}

4、针对锁的使用如何进行优化

以上就是Java代码层面对锁的实现,针对锁的性能优化,除了提到基于CAS操作的原子类,并发容器外,还有一些方法可以提高使用锁时的性能。

4-1、Java层面的代码优化

1、分段锁

就是将锁的数据分成多个段,每次只锁部分,这个是在JDK1.7之前ConcurrentHashMap实现的方法。

2、减小锁的范围

在程序代码中,我们通过减小锁的范围,来提升程序的性能,这样可以有效降低竞争发生的可能,减少串行执行的时间。锁的范围必须保证原子性(比如多个变量更新维持一个不变性条件的操作)

3、减小锁的粒度

也是就是为多个独立的变量用多个锁进行管理,比如统计用户登录和用户下单的数量,这两个数据互不影响,就可以用不同的锁区保护他们,这里需要注意的是,一个共享变量只能被一个锁保护,而一个锁可以保护多个共享变量。

4-2、无锁的技术手段

1、协程

英文Coroutines,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。

协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。

协程不是进程也不是线程,而是一个【特殊的函】数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行。

Java中目前没有关于协程的实现,通常在Go,Python和Lua中使用较多;

2、无锁编程Actor

除了CAS,还有一种无锁的技术手段,就是Actor框架。

Actor模型通过维护多个Actor去处理并发任务,它放弃了直接使用线程去获取并发性,而是自己定义了一系列组件 如何动作和交互的通用规则,不需要开发者直接使用线程。

通过在原生的线程或者协程级别做了更高层次的封装,只需要开发者关系每个Actor的逻辑,就可以实现并发操作。由于避免使用锁,很大程度解决传统并发编程模式下大量依赖悲观锁导致的资源竞争情况。

但是Actor也有自身的缺点:

  1. 首先,在Java中缺乏成熟的应用;
  2. 其次,内部复杂,难以排查和调试;