本文主要了解读写锁的获取规则,插队策略,升降级问题!

关于ReentrantLock参考
谈谈对锁的理解

读写锁ReadWriteLock

读写锁有两把锁,第一把是写锁,获取到写锁,既可以读数据也可以写数据。读锁只能查看数据,不能修改数据。读锁可以被多个线程同时拥有!

读写锁获取规则

  1. 一个线程已经占用了读锁,此时其他线程申请读锁,可以成功
  2. 一个线程已经占用了读锁,此时其他线程申请写锁,申请写锁的线程会一直等待读锁的释放。因为读写不能同时操作
  3. 一个线程已经占用了写锁,此时其他线程申请写锁或者读锁,都必须等待写锁的释放!

要么是一个或多个线程同时有读锁,要么是一个线程有写锁。也可以理解为“读读共享,其他互斥”。

使用案例

ReentrantReadWriterLock是ReadWriteLock的实现类,主要有两个方法:readLockwriteLock
代码如下:

/**
 * 描述:     演示读写锁用法
 */
public class ReadWriteLockDemo {


    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
            false);
    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
            .readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
            .writeLock();


    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放读锁");
        }
    }


    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放写锁");
        }
    }


    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }
}

执行结果

使用场景

适合读多,写少的情况!

读锁应该插队吗?读写锁的升级

读锁插队策略

ReentrantLock被设置为非公平时,释放锁的瞬间是可以被新线程抢占的。

当然ReentrantReadWriteLock也是可以设置为非公平和公平的。
公平锁:

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);

非公平锁:

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

在获取锁之前,获取读锁前线程会检查readerShouldBlock()方法,获取写锁前线程会检查writerShouldBlock()方法,去决定是去插队还是排队

公平锁对于这两种方法的实现:

final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

很明显,公平锁下,只要等待队列中有线程(hasQueuedPredecessors返回true),writer和reader都会block

非公平锁的实现:

final boolean writerShouldBlock() {
    return false; // writers can always barge
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}

对于想获取写锁的线程而言,返回的是false,随时可以插队。
而读锁不一样!


考虑下面的情况:

2和4在同时读,3想写!此时3进入等待队列!这时5突然要获取读锁

策略一、允许插队

读已经被2、4持有,加一个5毫无负担。

带来的影响是:读锁可以一直插队,导致写锁长时间处于等待,陷入“饥饿”状态

策略二、不允许插队


5在3后面排队,避免饥饿。

即便是非公平锁,只要等待队列头结点是尝试获取写的线程,那么读锁依然不能插队!

策略选择演示

public class ReadLockJumpQueue {
 
    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
            .readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
            .writeLock();
 
    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放读锁");
        }
    }
 
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放写锁");
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read(),"Thread-2").start();
        new Thread(() -> read(),"Thread-4").start();
        new Thread(() -> write(),"Thread-3").start();
        new Thread(() -> read(),"Thread-5").start();
    }
}

结果
从结果可以看出,即便是非公平的,线程5还是得等写锁释放才能获取到读锁。

锁的升降级

降级功能演示

public class CachedData {
 
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            //在获取写锁之前,必须首先释放读锁。
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                //这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
                if (!cacheValid) {
                    data = new Object();
                    cacheValid = true;
                }
                //在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
                rwl.readLock().lock();
            } finally {
                //释放了写锁,但是依然持有读锁
                rwl.writeLock().unlock();
            }
        }
 
        try {
            System.out.println(data);
        } finally {
            //释放读锁
            rwl.readLock().unlock();
        }
    }
}

processCachedData先获取到读锁(Lock.lock),先去判断缓存是否有效,有效直接跳过if,失效需要更新。由于读锁不够,还得获取写锁。
获取到写锁后,经典二次判断更新数据。然而我们还得打印data,所以还得持有读锁。也就是说在持有读写锁的情况下,释放了写锁。

为什么需要锁的降级

上面中虽然一直使用写锁,是线程安全的。但是只有一处是修改数据的,修改后面都是读数据,因此如过一直使用写锁就不能让其他线程来读取了。

支持锁的降级,不支持升级

下面代码在不释放读锁的情况下,尝试获取写锁----也就是升级,会让线程阻塞。

final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
public static void main(String[] args) {
    upgrade();
}
 
public static void upgrade() {
    rwl.readLock().lock();
    System.out.println("获取到了读锁");
    rwl.writeLock().lock();
    System.out.println("成功升级");
}

只会打印出“获取到读锁”,但不会打印“成功升级”。因为ReentrantReadWriteLock不支持读锁升级到写锁。

为什么不支持锁的升级

读写锁的特点是:线程都申请读锁,是可以多线程同时持有。如果是写锁,只能一个线程持有。


假设ABC都持有读锁。A尝试从读锁升级到写锁。那么它要等待B、C释放掉读锁。
随时间推移,确实可以升级到写锁。
考虑一种特殊情况。A、B都想要升级,显然就死锁了。

也不是不能升级,保证只有一个线程可以升级就可以了!

总结

对于ReentrantReadWriteLock而言:

  • 插队策略:

    • 公平:不允许插队
    • 非公平:

      • 如果允许读锁插队,那么写锁会饥饿
      • 写锁可以随时插队-----因为写锁必须在没有其他线程持有读锁和写锁的时候才能插队,因此它不容易插队成功
  • 升级策略:只能从写锁降级到读锁,不能从读锁升级到写锁

什么是自旋锁?自旋的好处和后果?

自旋可以理解为自我旋转,这里的旋转指循环。


自旋锁是不会放弃CPU时间片,而是通过自旋等待锁的释放-----即不停的尝试获取锁。
非自旋锁获取不到锁时会把自己的线程切换状态,让线程休眠,然后CPU可以在这段时间去做其他事,直到这把锁的线程释放了锁,于是CPU再把之前的线程恢复回来,让这个线程再次尝试去获取!

非自旋锁拿不到锁时会把线程阻塞!

自旋锁的好处

阻塞线程和唤醒线程都需要高昂的开销。

AtomicLong的实现

在Java1.5之后的版本,即java.util.concurrent包中,里面的原子类基本都是自旋锁的实现!

比如AtomicLong中的getAndIncrement方法:

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}

跟踪unsafe.getAndAddLong方法。

public final long getAndAddLong (Object var1,long var2, long var4){
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));


    return var6;
}

这个方法中有一个很明显的do while循环,这里的do-while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争没修改成功的情况,就会在while循环里进行死循环。

自己实现一个可重入的自旋锁

package lesson27;
 
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
 
/**
 * 描述:     实现一个可重入的自旋锁
 */
public class ReentrantSpinLock  {
 
    private AtomicReference<Thread> owner = new AtomicReference<>();
 
    //重入次数
    private int count = 0;
 
    public void lock() {
        Thread t = Thread.currentThread();
        if (t == owner.get()) {
            ++count;
            return;
        }
        //自旋获取锁
        while (!owner.compareAndSet(null, t)) {
            System.out.println("自旋了");
        }
    }
 
    public void unlock() {
        Thread t = Thread.currentThread();
        //只有持有锁的线程才能解锁
        if (t == owner.get()) {
            if (count > 0) {
                --count;
            } else {
                //此处无需CAS操作,因为没有竞争,因为只有线程持有者才能解锁
                owner.set(null);
            }
        }
    }
 
    public static void main(String[] args) {
        ReentrantSpinLock spinLock = new ReentrantSpinLock();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
                spinLock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
                    Thread.sleep(4000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    spinLock.unlock();
                    System.out.println(Thread.currentThread().getName() + "释放了了自旋锁");
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}

缺点和用应场景

自旋锁虽然避免线程切换的开销,但是会一直在做无效的重试!自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况!如果临界区很大,线程一旦拿到锁很久才会释放,那就不适合了!

JVM对锁的优化

JDK1.6之后,对synchronized内置锁做了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。

自适应的自旋锁

JDK1.6引入的自适应的自旋锁,通过多种因素-----比如最近尝试自旋是否成功等等---决定是否省略掉自旋过程。

锁消除

StringBuffer在的appen方法

@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

这个是线程安全的,但是很多情况下它只是被一个线程使用,所以编译器可能将synchronized消除!

锁粗化

如果线程释放了锁,紧着着什么都没做又获取了锁,比如:

public void lockCoarsening() {
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
}

这种释放重新获取完全没必要,把同步区扩大,在最开始加锁,在最后解锁,中间无意义的加锁解锁去掉!当然锁粗化会导致其它线程长时间无法获取锁!

循环场景不适合锁粗化!

锁粗化默认打开的,-XX:EliminateLocks可以关闭此功能!

Last modification:April 13th, 2020 at 09:57 pm