对于Java并发包来讲从Lock锁、AQS、独占锁、共享锁、LockSupport工具、Condition接口来分析Java并发包中的知识。
Lock锁
对于Lock锁而言,它是Java提供给开发者进行在多线程情况下使用的一个接口,用来完成锁的功能。在Java SE5之前实现锁的功能是通过synchronized来实现的,之后新增了Lock锁,相对于synchronized来说,Lock锁是显式的供开发者使用,在进行获取锁、释放锁时需要调用Lock锁中的方法,获取锁释放锁的过程对于开发者而言都是可见的,而synchronized则是隐式的获取锁以及释放锁,对于synchronized修饰的方法或者代码块,当线程方法到被修饰的方法或代码块时,会根据绑定的对象获取该对象的监视器锁,完成获取锁的功能,而这对于开发者不可见。对于两者的这种区别可以知道Lock锁对于开发者而言更加灵活,获取锁以及释放锁完全由开发者实现,同时在可中断获取锁以及超时获取锁都是synchronized关键字所不能提供的。
同时从使用层面上来说,Lock锁是Java提供的接口,是从API层面中进行使用的,而synchronized关键字则是在JVM层面进行使用的,synchronized的上锁以及解锁都是通过JVM来实现的。
对于使用Lock锁而言,在使用时一般都需要在逻辑中加入finally代码块,其中实现释放锁的逻辑,这样保证不论逻辑代码的成功与否都会释放锁,避免死锁的情况,同时在使用Lock锁时不建议在try代码块中执行获取锁的逻辑,这样如果出现获取锁出现异常,会导致锁无故被释放。
Lock锁提供了synchronized所不具备的三个特性,分别为尝试非阻塞获取锁、被中断的获取锁、超时获取锁。
尝试非阻塞获取锁:对于synchronized而言如果一个线程获取锁时锁已经被占用了那么该线程就会被阻塞放入同步队列中,而对于Lock锁而言,则可以尝试获取锁,也就是一个线程想要获取锁是调用尝试获取锁方法,尝试获取一下,如果该时刻没有其它线程占用锁,则该线程获取并持有锁,如果没有获取到则会直接放弃而不会进入阻塞队列中,防止该线程阻塞。
被中断的获取锁:跟synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常会被抛出,同时锁也会被释放。
超时获取锁:超时获取锁更好理解,就是在获取锁时设置一个时间范围,如果超过了这个时间范围,那么就会返回不会再继续获取锁,防止造成阻塞。
Lock接口中的方法主要有 lock、tryLock、unlock等方法。
AQS
AQS全称AbstractQueueSynchronizer,翻译过来就是抽象队列同步器,它主要的功能就是构建锁以及构建同步组件的基础框架,比如平时使用的ReenstrantLock、ReadWriteReentrantLock、CountDownLatch、Semphore等同步队列器都继承了AQS。AQS是实现这些锁以及同步组件的基础框架,在AQS的基础上进行开发适合自己的锁以及同步组件。
在我们使用AQS时主要是通过继承来实现的,子类继承AQS并实现其中的抽象方法来管理同步状态,因此当我们需要管理同步状态时,就需要通过AQS中的getState、setState、compareAndSetState方法进行操作,这样保证同步状态是安全的。
在我们去实现时一般通过静态内部类来继承AQS,因为我们通过查看AQS源码可以知道,AQS并没有实现任何同步接口,它仅仅是定义了一些获取以及操作同步状态的方法,所以要实现同步功能需要在子类的静态内部类中自己实现。同时AQS支持独占式的获取同步状态(ReentrantLock)也支持共享式的获取同步状态(ReentrantReadWriteLock)。
以ReentrantLock为例,在ReentrantLock类中实现了Sync内部类继承了AQS,并实现了同步方法。从这个层面我们可以知道ReentrantLock面向的是使用者,在ReentrantLock中定义了使用者操作锁的接口,隐藏了其中的细节(平时使用时直接调用lock方法即可),而AQS则面向的是锁的实现者(通过定义Sync内部类继承AQS就是面向的锁的实现者),通过AQS简化了锁的实现方式,并且对于实现者而言屏蔽了同步队列状态管理、线程的排队、线程的等待以及唤醒等底层的操作。这样就很好的隔离了使用者以及实现者各自的领域,使用者关注使用,而锁的实现者则关注对于锁的实现。
查看AQS源码可以知道其中实现了两套同步方法,分别是独占式与共享式,独占式类似于acquire这种,而共享式则一般在最后添加Shared标记该方法是共享式的同步方法,自己实现时可以根据自己的需求是独占式还是共享式分别重写对应的方法。
在AQS中包括同步队列、独占式同步状态的获取与释放、共享式同步状态获取与释放等实现方法。
同步队列
对于同步队列而言,它是一个FIFO的双向队列,它主要的功能是对同步状态的管理。当一个线程获取同步状态失败时,AQS会将该线程以及等待状态等信息构造成一个节点(Node节点)将其加入同步队列,同步阻塞该线程,等同步状态释放时,会将同步队列中的首节点中的线程唤醒并使其再次尝试获取同步状态。
所以在AQS中同步队列主要起到一个缓冲区的作用,通过存储获取同步状态失败的线程以及对应的状态,等待同步状态被释放,同步队列中的节点再次被唤醒重新尝试获取同步状态。
同步队列是一个双向队列,遵循FIFO,那么就要保证插入到同步队列的Node节点是顺序的,所以插入同步队列的操作也必须是线程安全的,而AQS在保证插入同步队列的操作线程安全则使用了CAS算法来设置尾节点(因为每次节点的加入都是添加到尾节点),从源码中可以看到该方法的实现为compareAndSetTail(Node expect,Node update)方法,该方法需要传递当前尾节点以及需要更新的尾节点,保证尾节点是自己期望的节点。因为遵循FIFO所以首节点是获取同步状态成功的节点,而后继节点则会成为首节点。
独占式同步状态获取与释放
独占式顾名思义就是同一时刻只有一个线程可以获取到同步状态,其它线程会被阻塞,知道获取到同步状态的线程释放同步状态。而AQS是如何实现该方法的呢?
先从源码中查看可以知道是调用acquire(int arg)方法来获取同步状态的
该方法可以知道首先调用tryAcquire(该方法是通过自定义同步器实现的),该方法保证获取同步状态是线程安全的并且如果同步状态获取失败,那么会调用addWaiter方法将该线程加入到同步队列尾部,最后通过acquireQueued方法以自旋的方式让该节点循环获取同步状态。
AQS为了保证Node节点被顺序的添加,通过调用enq方法来保证顺序性,在enq方法中通过死循环的方法将节点加入到同步队列中,只有CAS设置节点成功,该方法才会返回,否则一直不断的尝试设置。因此可以知道enq方法通过是并发的将加节点的请求通过CAS变得串行化了。
对于独占式获取释放锁而言,在获取同步状态时,AQS维护了一个同步队列,获取同步状态失败的线程会被加入到同步队列中,并且在队列中也会通过自旋的方式尝试获取同步状态,能够成功获取到同步状态的标准是该线程的节点是否是首节点,当释放同步状态时,会通知后继节点,后继节点可以获取到释放的同步状态。
共享式同步状态的获取与释放
共享式与独占式相反,在获取同步状态时可以有多个线程获取。
在AQS中通过调用acquireShared(int arg)方法可以共享式的获取同步状态,
可以看到在acquireShared方法中通过判断tryAcquireShared方法的返回值来确定是否能够获取同步状态,当tryAcquireShared返回值大于0时表示能够获取到同步状态。因此共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件是tryAcquireShared返回值大于等于0。
重入锁
重入锁顾名思义就是支持一个线程重复获取锁。这样的设计保证了一个线程获取到锁之后再次获取该锁不会被锁阻塞。
当一个线程再次获取锁时锁只要判断当前占据锁的线程是不是该线程,如果是那么就获取成功,同样的一个线程获取了多次锁那么在释放锁时也需要释放多次,知道计数器为0时才释放完全。
在前面提到的ReentrantLock以及synchronized锁都支持重入锁,当然了synchronized依然是隐式的重入锁,线程再次获取锁的过程对开发者不可见,而ReentrantLock在调用lock获取锁时,如果第二次获取锁那么调用lock不会被阻塞。
在synchronized中的重入锁是通过监视器锁来实现的,也就是获取监视器锁的次数,当synchronized修饰代码块时通过计算进入moniterenter的次数来判断线程获取了几次锁,最后通过moniterexit进行释放锁,同样的进入几次就需要释放几次锁,完成最终的释放锁。
synchronized修饰方法时,是通过ACC_SYNCHRONIZED标识符来获取监视器锁,JVM进行编译时如果检测到方法中存在ACC_SYNCHRONIZED标识符,那么会获取监视器锁,如果进入多次那么会获取多次该锁,同样的最后也需要释放多次,完成最终锁的释放。
公平锁与非公平锁
公平锁与非公平锁是相对的概念,公平锁遵循FIFO的规则,最先进入同步队列的线程最先获取锁,而非公平锁则没有该限制,当同步状态被释放后,在同步队列中的所有线程都有机会获取同步状态。
以ReentrantLock为例,ReentrantLock支持公平锁与非公平锁,它通过构造函数来实现对公平锁与非公平锁的设置。
可以看到ReentrantLock默认是非公平锁,并且通过传递boolean参数设置公平与非公平锁。
非公平锁的实现比较好理解只要CAS(compareAndSetState方法)设置成功,那么线程就成功获取了锁
而公平锁则不同,实现方法上通过hasQueuedPredecessors()方法完成公平策略,当调用hasQueuedPredecessors()方法是会判断当前节点是否有前驱节点,如果有表明同步队列中有更早的请求锁的线程,该线程会继续阻塞,如果没有就说明该节点是头结点,可以获取同步状态。
读写锁
前面提到的ReentrantLock是独占锁,而共享锁的实现则是ReentrantWriteReadLock,ReentrantWriteReadLock是一个读写锁,也就是既支持读锁也支持写锁,当然了这里也有一些限制,读锁支持多线程读,而写锁只支持单线程写,同时如果一个线程已经获取了写锁,那么其它线程读锁也会被阻塞,这样的设计保证数据的一致性以及可见性。
ReentrantLock通过被volatile修饰的整形变量state作为锁的标志位,判断锁是否被占用以及可重入锁等状态,而ReentrantWriteReadLock为了兼顾读锁和写锁,就需要“按位切割“使用这个变量,读写锁将变量分成两部分,高16位表示读,低16位表示写,因此对于读锁和写锁的状态以及次数的判断通过位运算来实现。
在这里写锁是一个支持重入的排它锁,读锁是支持重入的共享锁,能够被多个线程同时获取。对于读锁而言每个线程各自获取读锁的次数是保存在ThreadLocal中的,由线程自身维护。
读写锁支持锁降级功能,锁降级的意义是写锁降级为读锁,意思是当一个线程拥有写锁,再获取读锁,随后释放写锁的过程。这样的设计可以保证数据的可见性,因此如果该线程不获取读锁而是直接释放写锁,而另一个线程获取了写锁并修改了数据,那么当前线程无法感知到其它线程修改的数据,当该线程再次获取读锁时,获取到的数据可能不是最新的数据。而锁降级则很好的保证了数据的可见性。
Condition接口
在Java中任意一个对象都有一组监视器方法,包括wait、notify、notifyAll,这些方法与synchronized配合,实现线程之间的通信,而Condition接口也提供了类似Object的监视器方法,与Lock配合实现线程的通信。
Condition中断方法包括await、signal、signalAll,而线程调用这些方法时需要获取Condition对象关联的锁,Condition对象是由Lock对象创建出来的,也就是Condition依赖Lock对象。
`Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();`
当一个线程调用await方法后,该线程会释放锁并在此等待,直到其它线程调用Condition对象的signal方法,通知当前线程后,当前线程才会从await方法返回,并且返回前已经获取了锁。
Condition的实现依赖AQS的,前面说过Condition的操作需要获取相关联的锁,那么Condition的实现就是将Condition作为AQS中的一个内部类来实现的。同时每个Condition对象都是一个等待队列,等待队列是一个FIFO队列,队列中的每个节点都包含一个线程的引用,该线程就是在Condition对象上等待的线程(调用await方法的线程),如果一个线程调用了await方法,那么该线程会释放锁,并将该线程构造成节点加入到等待队列中。
等待队列拥有头结点和尾节点,当一个节点加入到等待队列中时,只需要将尾节点指向该节点,并且可以发现加入到等待队列的操作并没有使用CAS来保证线程安全,这是因为调用await方法的线程必定是获取了锁的线程,也就是等待队列的线程安全是由锁来保证的。
并且一个Lock对象可以拥有多个Condition对象,也就是可以拥有多个等待队列,通过前面可以总结,一个Lock对象拥有一个同步队列和多个等待队列。
将同步队列和等待队列联系起来可以直到,当一个线程调用await方法,相当于线程从同步队列的首节点移动到某个等待队列的尾节点。
当调用signal方法时,会唤醒等待队列中等待时间最长的节点(首节点),在唤醒之前会将节点移到同步队列中。