互斥锁解决原子性问题

在多核架构的系统下,可能同时有多个程序正在被执行,如果不加限制的访问共享资源,将会影响程序运行的正确性。
保证访问共享资源的进程之间是互斥的,就能保证程序执行的原子性。

通过 synchronized 实现的管程

在Java中,通过 synchronized 关键字提供互斥原语,对对象尝试加锁,如果成功,则进入临界区。否则就等待,直到持有锁的线程解锁后重新尝试加锁。

synchronized 修饰的对象,在执行时会自动进行加锁和解锁操作。根据内存模型的管程中锁的规则
可以得出:前一个线程在临界区对共享变量的修改,对后续进入临界区的线程是可见的。

等待-通知机制预防死锁

在预防死锁发生的占有且等待的条件时,如果通过死循环的方式不停的尝试需要占用的所有资源,并发量大时,对性能会产生恶劣的影响。可通过等待-通知机制来改进。

原理:

  1. 线程首先获取互斥锁(通过 synchronized 关键字),
  2. 当要求的条件不满足时,释放互斥锁,进入 等待 状态(调用 wait() 方法);
  3. 待要求的条件满足后, 通知 等待的线程(调用 notify()notifyAll() 方法);
  4. 被通知的线程需要重新判断条件是否满足(只保证在通知的当下,条件是满足的),重新获取互斥锁。
  • 即便通知所有线程(通过调用 notifyAll() 方法),最终也只会有一个线程进入临界区。
  • 只通知一个线程(通过调用 notify() 方法),可能会导致某些线程永远不会被通知到。

唤醒阻塞的线程破除死锁的不可抢占条件

通过 synchronized 申请资源如果申请不到会阻塞线程。一旦发生死锁,没有机会唤醒阻塞的线程。

Lock接口提供的方法支持阻塞的线程被唤醒,从而有机会释放曾经持有的锁,破除了不可抢占条件,预防了死锁的发生。

Lock锁的可见性保证

ReentrantLock 内部持有一个 volatile 的成员变量 state ,获取锁和解锁的时候,会读写 state 值,从而保证了解锁相对于后续对这个锁的加锁是可见的。


class X {
    private final Lock rtl = new ReentrantLock();
    int value;
    public void addOne() {
        // 获取锁
        rtl.lock();  
        try {
            value+=1;
        } finally {
            // 保证锁能释放
            rtl.unlock();
        }
    }
}

ReentrantLock 是可重入锁,即拥有该锁的线程,可再次成功的对该锁执行加锁操作。( synchronized 也是可重入锁 )

Lock锁和synchronized的区别

用锁的最佳实践

  1. 永远只在更新对象的成员变量时加锁

  2. 永远只在访问可变的成员变量时加锁

  3. 永远不在调用其他对象的方法时加锁

    调用其他对象的方法,实在是太不安全了,也许 其他 方法里面有线程 sleep() 的调用,也可能会有奇慢无比的 I/O 操作,这些都会严重影响性能。更可怕的是,其他 类的方法可能也会加锁,然后双重加锁就可能导致死锁。

通过 LockCondition 实现的管程

synchronized 实现的管程只有一个条件,通过 Lock&Condition 实现的管程支持多个条件:

信号量

信号量是保证临界区安全的另一种手段。

PV操作

信号量模型提供三个原子性的方法给外界访问:

例:


static int count;
//初始化信号量
static final Semaphore s = new Semaphore(1);
//用信号量保证互斥    
static void addOne() {
  s.acquire();
  try {
    count+=1;
  } finally {
    s.release();
  }
}

多个线程同时访问临界区

与互斥锁不同,信号量可以允许多个线程同时访问同一个临界区。

使用信号量模型实现的对象池:

class ObjPool<T, R> {
    final List<T> pool;
  
    // 用信号量实现限流器
    final Semaphore sem;

    ObjPool(int size, T t){
        pool = new Vector<T>(){};
        for(int i = 0; i < size; i++){
            pool.add(t);
        }
        sem = new Semaphore(size);
    }

    // 利用对象池的对象,调用func
    R exec(Function<T, R> func) {
        T t = null;
        sem.acquire();
        try {
            t = pool.remove(0);
            return func.apply(t);
        } finally {
            pool.add(t);
            sem.release();
        }
    }
}

// 创建对象池
ObjPool<Long, String> pool = new ObjPool<Long, String>(10, 2);
// 通过对象池获取t,之后执行  
pool.exec(t -> {
    System.out.println(t);
    return t.toString();
});

ReadWriteLock锁

ReadWriteLock锁遵循三个基本原则:

  1. 允许多个线程同时获得读锁;
  2. 只允许一个线程获得写锁;
  3. 如果一个线程获得了写锁,禁止其他线程获得锁。

例,一个缓存工具类:


class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    
    // 读锁
    final Lock r = rwl.readLock();
    
    // 写锁
    final Lock w = rwl.writeLock();

    // 读缓存
    V get(K key) {
        r.lock();
        try {
            return m.get(key);
        }
        finally {
            r.unlock();
        }
    }

    // 写缓存
    V put(K key, V value) {
        w.lock();
        try { 
            return m.put(key, v);
        }
        finally {
            w.unlock();
        }
    }
}

读写锁不支持升级

获取读锁后,在释放读锁之后,能够成功获取写锁,称为锁的升级。读写锁中,如果没有释放读锁就尝试获得写锁,会导致永久等待。

读写锁支持降级

获取写锁后,在释放之前,能够成功获得读锁,称为锁的降级

StampedLock锁

StampedLock锁支持三种模式:

  1. 写锁

  2. 悲观读锁

  3. 乐观读

该操作是无锁的。因此性能比读写锁好。

例:


class Point {
    private int x, y;
    final StampedLock sl = new StampedLock();

    // 计算到原点的距离  
    int distanceFromOrigin() {
        // 乐观读
        long stamp = sl.tryOptimisticRead();
    
        // 读入局部变量,过程中数据可能被修改
        int curX = x, curY = y;
    
        // 判断执行读操作期间,
        // 是否存在写操作,如果存在,
        // 则sl.validate返回false
        if (!sl.validate(stamp)) {
            // 升级为悲观读锁
            stamp = sl.readLock();
            try {
                curX = x;
                curY = y;
            } finally {
                //释放悲观读锁
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(curX * curX + curY * curY);
    }
}

注意事项:

  1. StampedLock不支持重入。
  2. StampedLock的悲观读锁、写锁不支持条件变量。
  3. 线程阻塞在锁上时,如果调用阻塞线程的 interrupt() 方法,会导致CPU占用飙升。如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()