并发编程的实现原理-Lock-笔记(AQS)

同步锁java

  • 锁是用来控制多个线程访问共享资源的方式
  • 通常来讲,一个锁可以防止多个线程同时访问共享资源,
  • 在Lock接口出现以前,Java应用程序只能依靠synchronized关键字来实现同步锁的功能,
  • 在java5之后,增长了JUC(java.util.concurrent)的并发包且提供了Lock接口用来实现锁的功能,
    • 它提供了与synchroinzed关键字相似的同步功能,
    • 只是它比synchronized更灵活,可以显式的获取和释放锁。

Lock的初步使用面试

  • Lock是一个接口,核心的两个方法lock和unlock,
  • 它有不少的实现,好比ReentrantLock、ReentrantReadWriteLock;

ReentrantLock缓存

  • 重入锁,表示支持从新进入的锁,
  • 也就是说,若是当前线程t1经过调用lock方法获取了锁以后,
  • 再次调用lock,是不会再阻塞去获取锁的,
  • 直接增长重试次数就好了。
public class AtomicDemo {
    private static int count = 0;
    static Lock lock = new ReentrantLock();

    public static void inc() {
        lock.lock();
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
        lock.unlock();
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                AtomicDemo.inc();
            }).start();
        }
        Thread.sleep(3000);
        System.out.println("result:" + count);
    }
}

ReentrantReadWriteLock安全

  • 咱们之前理解的锁,基本都是排他锁,
  • 也就是这些锁在同一时刻只容许一个线程进行访问,
  • 而读写锁在同一时刻能够容许多个线程访问,
    • 可是在写线程访问时,全部的读线程和其余写线程都会被阻塞。
  • 读写锁维护了一对锁,一个读锁、一个写锁;
  • 通常状况下,读写锁的性能都会比排它锁好,
    • 由于大多数场景读是多于写的。
  • 在读多于写的状况下,读写锁可以提供比排它锁更好的并发性和吞吐量.
public class LockDemo {
    static Map<String, Object> cacheMap = new HashMap<>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock read = rwl.readLock();
    static Lock write = rwl.writeLock();

    public static final Object get(String key) {
        System.out.println("开始读取数据");
        read.lock(); //读锁
        try {
            return cacheMap.get(key);
        } finally {
            read.unlock();
        }
    }

    public static final Object put(String key, Object value) {
        write.lock();
        System.out.println("开始写数据");
        try {
            return cacheMap.put(key, value);
        } finally {
            write.unlock();
        }
    }
}
  • 在这个案例中,经过hashmap来模拟了一个内存缓存,
    • 而后使用读写锁来保证这个内存缓存的线程安全性。
  • 当执行读操做的时候,须要获取读锁,在并发访问的时候,读锁不会被阻塞,
    • 由于读操做不会影响执行结果。
  • 在执行写操做是,线程必需要获取写锁,当已经有线程持有写锁的状况下,
    • 当前线程会被阻塞,只有当写锁释放之后,其余读写操做才能继续执行。
  • 使用读写锁提高读操做的并发性,也保证每次写操做对全部的读写操做的可见性
    • l 读锁与读锁能够共享
    • l 读锁与写锁不能够共享(排他)
    • l 写锁与写锁不能够共享(排他)

Lock和synchronized的简单对比数据结构

  • 经过咱们对Lock的使用以及对synchronized的了解,基本上能够对比出这两种锁的区别了。
  • 由于这个也是在面试过程当中比较常见的问题::
    • Ø 从层次上,一个是关键字、一个是类, 这是最直观的差别
    • Ø 从使用上,lock具有更大的灵活性,能够控制锁的释放和获取;
      • 而synchronized的锁的释放是被动的,当出现异常或者同步代码块执行完之后,才会释放锁
    • Ø lock能够判断锁的状态、而synchronized没法作到
    • Ø lock能够实现公平锁、非公平锁;
      • 而synchronized只有非公平锁

AQS(AbstractQueuedSynchronizer多线程

  • Lock之因此能实现线程安全的锁,
  • 主要的核心是AQS(AbstractQueuedSynchronizer),
  • AbstractQueuedSynchronizer提供了一个FIFO队列,
    • 能够看作是一个用来实现锁以及其余须要同步功能的框架。
  • AQS的使用依靠继承来完成,
    • 子类经过继承自AQS并实现所需的方法来管理同步状态。
    • 例如常见的ReentrantLockCountDownLatch等AQS的两种功能
  • 从使用上来讲,AQS的功能能够分为两种:独占和共享。
  • 独占锁模式下,每次只能有一个线程持有锁,
    • 好比前面给你们演示的ReentrantLock就是以独占方式实现的互斥锁
  • 共享锁模式下,容许多个线程同时获取锁,并发访问共享资源,
    • 好比ReentrantReadWriteLock。
  • 很显然,独占锁是一种悲观保守的加锁策略,它限制了读/读冲突,
    • 若是某个只读线程获取锁,则其余读线程都只能等待,
    • 这种状况下就限制了没必要要的并发性,
    • 由于读操做并不会影响数据的一致性。
  • 共享锁则是一种乐观锁,它放宽了加锁策略,
    • 容许多个执行读操做的线程同时访问共享资源

AQS的内部实现并发

  • 同步器(AQS依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,
  • 当前线程获取同步状态失败时,
    • 同步器会将当前线程以及 等待状态 等信息
    • 构形成为一个节点(Node)并将其加入同步队列,
    • 同时会阻塞当前线程,
  • 当同步状态释放时,会把首节点中的线程唤醒,
    • 使其再次尝试获取同步状态。

Node的主要属性以下app

static final class Node {
        int waitStatus; //表示节点的状态,包含cancelled(取消);condition 表示节点在等待condition 也就是在condition队列中
        Node prev; //前继节点
        Node next; //后继节点
        Node nextWaiter; //存储在condition队列中的后继节点
        Thread thread; //当前线程
    }
  • AQS类底层的数据结构是使用双向链表,是队列的一种实现。
    • 包括一个head节点和一个tail节点,
    • 分别表示头结点和尾节点,
    • 其中头结点不存储Thread,仅保存next结点的引用。

  • 当一个线程成功地获取了同步状态(或者锁),
    • 其余线程将没法获取到同步状态,
    • 转而被构形成为节点并加入到同步队列中,
  • 而这个加入队列的过程必需要保证线程安全,
    • 所以同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate)
      • 它须要传递参数当前线程“认为”的尾节点当前节点,只有设置成功后,
      • 当前节点才正式与以前的尾节点创建关联。

  • 同步队列遵循FIFO,
    • 首节点是获取同步状态成功的节点,
  • 首节点的线程在释放同步状态时,
    • 将会唤醒后继节点,
    • 然后继节点将会在获取同步状态成功时
      • 将本身设置为首节点。

  • 设置首节点是经过获取同步状态成功的线程来完成的,
    • 因为只有一个线程可以成功获取到同步状态,
    • 所以设置头节点的方法并不须要使用CAS来保证,
    • 它只须要将首节点设置成为原首节点的后继节点
    • 并断开原首节点的next引用便可

compareAndSet框架

  • AQS中,除了自己的链表结构之外,
    • 还有一个很关键的功能,就是CAS,
    • 这个是保证在多线程并发的状况下、保证线程安全的前提下
      • 去把线程加入到AQS中的方法,能够简单理解为乐观锁
private final boolean compareAndSetHead(Node update) {
     return unsafe.compareAndSwapObject(this, headOffset, null, update);
}
  • 首先,用到了unsafe类,
    • Unsafe类是在sun.misc包下,不属于Java标准。
    • 可是不少Java的基础类库,包括一些被普遍使用的高性能开发库都是基于Unsafe类开发的,
    • 好比Netty、Hadoop、Kafka等;
    • Unsafe可认为是Java中留下的后门,提供了一些低层次操做,
      • 直接内存访问线程调度
  • 而后调用了compareAndSwapObject这个方法
    • public final native boolean compareAndSwapObject(Object var1, long var2, Object var4,Object var5);

       

      • 这个是一个native方法,
      • 第一个参数为须要改变的对象,
      • 第二个为偏移量(即以前求出来的headOffset的值),
      • 第三个参数为期待的值,
      • 第四个为更新后的值
    • 整个方法的做用是
      • 若是当前时刻的值等于预期值var4相等,
        • 则更新为新的指望值 var5,
      • 若是更新成功,则返回true,不然返回false;

这里传入了一个headOffset,这个headOffset是什么呢?oop

  • 在下面的代码中,经过unsafe.objectFieldOffset

  • 而后经过反射获取了AQS类中的成员变量,
    • 而且这个成员变量被volatile修饰的

unsafe.objectFieldOffset

  • headOffset这个是指类中相应字段在该类的偏移量,
    • 在这里具体便是指head这个字段
    • 在AQS类的内存中相对于该类首地址的偏移量
  • 一个Java对象能够当作是一段内存,
    • 每一个字段都得按照必定的顺序放在这段内存里,
    • 经过这个方法能够准确地告诉你
      • 某个字段相对于对象的起始内存地址的字节偏移。
    • 用于在后面的compareAndSwapObject中,
      • 去根据偏移量找到对象在内存中的具体位置

这个方法在unsafe.cpp文件中,代码以下::

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapObject(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jobject e_h, jobject x_h))
UnsafeWrapper("Unsafe_CompareAndSwapObject");
oop x = JNIHandles::resolve(x_h); // 新值
oop e = JNIHandles::resolve(e_h); // 预期值
oop p = JNIHandles::resolve(obj);
HeapWord* addr = (HeapWord *)index_oop_from_field_offset_long(p, offset);// 在内存中的具体位置
oop res = oopDesc::atomic_compare_exchange_oop(x, addr, e, true);// 调用了另外一个方法,实际上就是经过cas操做来替换内存中的值是否成功
jboolean success = (res == e); // 若是返回的res等于e,则断定知足compare条件(说明res应该为内存中的当前值),但实际上会有ABA的问题
if (success) // success为true时,说明此时已经交换成功(调用的是最底层的cmpxchg指令)
update_barrier_set((void*)addr, x); // 每次Reference类型数据写操做时,都会产生一个Write Barrier暂时中断操做,配合垃圾收集器
return success;
UNSAFE_END
  • 因此其实compareAndSet这个方法,
    • 最终调用的是unsafe类的compareAndSwap,
    • 这个指令会对内存中的共享数据作原子的读写操做。
      • 1. 首先, cpu会把内存中将要被更改的数据与指望值作比较
      • 2. 而后,当两个值相等时,cpu才会将内存中的对象替换为新的值。
        • 不然,不作变动操做
      • 3. 最后,返回操做执行结果
  • 很显然,这是一种乐观锁的实现思路。
相关文章
相关标签/搜索