Java并发编程实践

内部锁

synchronized块有两个部分:锁对象的引用以及保护的代码块。
方法的锁是方法所在的对象本身,静态方法的锁是Class对象。
每个java对象都可以隐式的作为同步的锁的角色:这些内置的锁被称为内部锁(intrinsic locks)或者监视器锁(monitor locks)。获得内部锁的唯一方法就是:进入这个内部锁保护的方法或者代码块。
内部锁在java中扮演了互斥锁(mutual exclusion locks,也被称为mutex)的角色。

重进入(Reentrancy)

内部锁是可以重进入的,线程请求别的线程的锁会拒绝,自己线程的锁可以获得,根据计数器数量确认,当为0时释放锁。

Java监视器模式(Java monitor pattern)

对于遵循Java监视器模式的对象,会将对象所有的可变对象给封闭起来,并由对象自己的内置锁进行保护。

/**
 * @author fenghongyu
 */
public class PrivateLock {
    private Object object = new Object();

    private Integer value;

    public void addValue(Integer val) {
        synchronized (object){
            value +=val;
        }
    }
}

监视器模式是一种编码约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。
使用私有对象锁,相比使用公有对象锁,有如下好处:
私有的锁对象,可避免客户获取到锁,仅能通过方法来访问锁,以便合理的参与到它的同步策略中。
如果要验证某公有锁对象是否被正确使用,需要查询所有使用到的代码,但私有锁,仅需要核对私有锁对象所在的类。

ConcurrentModificationException

对Collection进行迭代的标准方式是使用Iterator,无论是显式的使用还是通过Java5.0引入的新的for-each循环语法。当存在其他线程并发修改容器时,使用迭代器(Iterator)不可避免地需要再迭代期间对容器加锁。在设计同步容器返回的迭代器时,并没有考虑到并发修改的问题,它们是“及时失败(fail-fast)”的,意思是当他们察觉容器在迭代开始后被修改,会抛出该异常。
隐藏迭代器
有时候迭代器是隐藏的,比如是HashSet的toString方法。

死锁(Deadlock)、饥饿(Starvation)、活锁(Livelock)

死锁,饥饿,活锁都属于多线程情况下的线程活跃性问题
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。
此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
饥饿指的是某一个或者多个线程无法获得所需要的资源,导致一直无法执行。
可能的情况包括
线程优先级过低,高级的线程不断抢占它需要的资源
某线程长时间占用关键资源不放
与死锁相比,饥饿是可能在未来一段时间内解决的(比如高级线程完成任务,不再疯狂的执行)
活锁指的是线程都秉承着"谦让"的原则,主动将资源释放给他人使用,导致资源不断在多个线程中跳动,没有一个线程可以同时拿到所有的资源而正常执行.

ConcurrentHashMap

之前的并发Hash表使用一个公共锁同步每一个方法,并且严格的限制只能有一个线程可以同时访问容器。ConcurrentHashMap使用一个更加细化的锁机制,叫做分离锁。这个机制运行更深测的共享访问,任意数量的读线程可以并发访问Map,读写也可以并发访问Map,并且有限数量的写线程还可以并发修改Map,为并发访问带来更高的吞吐量,同时几乎没有损失单个线程访问的性能。
ConcurrentHasMap和其他并发容器一起改进了同步容器类,提供不会抛出ConcurrentModificationException的迭代器。返回的迭代器具有弱一致性,而非及时失败。
类似size和isEmpty方法可能并非即时的。

阻塞队列和生产者-消费者模式

类库中包含一些BlockingQueue的实现,其中LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,PriorityBlockingQueue是一个按优先级排序的队列。最后一个BlockingQueue的实现是SynchronousQueue。
生产者消费者之间要进行对象所有权安全移交,阻塞队列提供了连续的线程限制来保证(serial thread confinement)。一个线程约束的对象完全由单一线程所有,但是所有权可以通过安全的发布被“转移”,这样其他的线程中只有唯一一个能够得到这个对象的访问权,并且保证移交之后原线程不能再访问它。阻塞队列可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来完成。
双端队列和窃取工作(Deque和BlockQDeque),每一个消费者有自己的双端队列,如果完成之后就去窃取其他消费者的双端队列的末端任务。

阻塞和可中断的方法

线程会因为几种原因被阻塞或暂停:等待IO操作结束,等待获得一个锁,等待从Thread.sleep中唤醒,或者是等待另一个线程的计算结果。
当一个线程阻塞时,他通常被挂起,并且设置成线程阻塞的某个状态(BLOCKED、WAITING、TIMED_WAITING),等到外部事件的发生触发将线程置回(RUNNABLE)状态重新获得调度的机会。

Synchronizer

Synchronizer是一个对象,他根据本身的状态调节线程的控制流。包含:BlockingQueue(阻塞队列),Semaphore(信号量)、Barrier(关卡)、闭锁(Latch)。
CountDownLatch:比如有一个任务A,它要等待其他4个任务执行完毕之后才能执行,此时就可以利用CountDownLatch来实现这种功能了。
CyclicBarrier:假若有若干个线程都要进行写数据操作,并且只有所有线程都完成写数据操作之后,这些线程才能继续做后面的事情,此时就可以利用CyclicBarrier了
Semaphore:一个工厂有5台机器,但是有8个工人,一台机器同时只能被一个工人使用,只有使用完了,其他工人才能继续使用。那么我们就可以通过Semaphore来实现

FutureTask

FutureTask是一种闭锁,是通过Callable实现的,它等价与一个可携带结果的Runnable,并且有3个状态:等待、运行、完成。完成包括所有方式结束,正常结束、取消、异常。一旦进入完成会永远停在这个状态上。
Future.get的行为依赖于任务的状态。如果完成可以立刻获得结果,否则会被阻塞到完成为止。

Executor框架

使用有界队列防止应用程序过载而耗尽内存。Executor框架提供了一个灵活的线程池实现。它为任务提交任务执行之间的解耦提供了标准的方法,为使用Runnable描述任务提供了通用的方法。来ExecutorService扩展了Executor的接口,加入了生命周期管理。生命周期有三种,运行(Running),关闭(Shutting down)和终止(terminated)。一开始是运行,shutdown的时候停止接受新任务同时等已经提交的任务完成,terminate(shutdownNow)会尝试取消已经提交的任务。关闭后提交过来的任务会被拒绝执行处理器(rejected execution handler)处理。
ExecutorService中所有的submit方法都会返回一个Future。
大量互相独立且同类的任务进行并发处理,会将程序的任务量分配到不同的任务中,这样才能真正获得性能的提升。

CompletionService:Executor遇见BlockingQueue

ExecutorCompletionService,在构造函数中创建一个BlockingQueue,用其保存Executor执行后的结果。

中断与取消

中断并不会真正中断一个正在运行的线程,它仅仅发出了一个中断请求,线程会在自己方便的时候中断(称之为中断点,cancellation point)。
中断常是现实取消最明智的选择

队列饱和

当一个有限队列饱和后,饱和策略开始起作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler

减小锁的粒度

通过分拆锁(lock splitting) 分离锁(lock striping)
分离锁:
ConcurrentHashMap的实现使用了一组包含16个锁的Array,每一个锁都守护HashBucket的1/16。
分离锁的缺点是对容器加锁独占访问更加困难与昂贵了。例如ConcurrentHashMap的值需要扩展、重排、放入一个更大的Bucket中时,需要获取所有分离锁。

在切换时,一个进程存储在处理器各寄存器中的中间数据叫做进程的上下文,所以进程的切换实质上就是被中止运行进程与待运行进程上下文的切换。在进程未占用处理器时,进程 的上下文是存储在进程的私有堆栈中的。  

显示锁(Explicit Locks)

Lock和ReentrantLock

Java5.0提供了ReentrantLock,这并不是内部锁的替代,而是在内部锁受到局限的时候提供的高级特性。与内部加锁机制不同,Lock提供了无条件、可轮询、定时的、可中断的锁获取操作,所有加锁和解锁的方法都是显式的。Lock的实现必须提供具有与内部加锁相同的内存可见性的语义。
内部锁大部分情况下都能很好的工作,但是有一些功能上的局限:不能中断目前正在等待获取锁的线程,并且在请求锁资源失败必须无限等待。
synchronized在离开代码块自动释放锁,ReentrantLock必须显式地在final释放锁,这是风险点。

公平性

ReentrantLock提供了两种公平性的选择:创建非公平锁(默认)或者非公平锁。
在你需要:可定时、可轮询、可中断的锁获取操作,公平队列或者非块结构的锁的时候再考虑使用ReentrantLock,否则请考虑使用synchronized。

读写锁

ReentrantLock实现了标准的互斥锁。ReadWriteLock,读写锁维护了两个锁,只读锁可以被多个线程获取,写锁只能有一个线程获取,两个锁是互斥的。
Java语言的同步机制在底层实现上只有两种手段:"互斥"和"协同".体现在Java语言层面上,就是内置锁和内置条件队列.内置锁即synchronized。内置条件队列这个词大家或许没有听过,他指的是Object.wait(),Object.notify(),Object.notifyAll()三种方法,就是条件队列方法(内部条件队列的API)。Java每个对象都可以作为锁,每个对象也都能作为条件队列。一个对象的内部锁和内部条件队列是相关的:为了调用对象X的任一个队列方法,必须要持有对象X的锁。除非你能检查状态,否则你不能等待条件;除非你能改变状态,否则你不能从条件等待队列中释放其他的线程。
首先队列大家一定都清楚,一种先进先出的数据结构.正常的队列中存储的都是对象,而条件队列中存储的是"处于等待状态的线程",这些线程在等待某种特定的条件变成真。正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,这个对象的wait,notify,notifgAll就构成了内部条件队列的API。对象的内置锁与条件队列是相互关联的。要调用条件队列的任何一个方法,必须先持有该对对象上的锁。没懂的话多读几遍,慢慢理解。
我们都知道,线程是用来干活的,没人想让线程始终处于等待状态.因此"条件队列中的线程一定是执行不下去了才处于等待状态",这个"执行不下去的条件"叫做"条件谓词".
至此,我们知道了3个重要的概念:锁,条件谓词,条件队列。虽然他们的关系并不复杂,但是一定要注意,wait()方法的返回并不一定意味着正在等待的条件谓词变成真了。举个列子:假设现在有三个线程在等待同一个条件谓词变成真,然后另外一个线程调用了notifyAll()方法。此时,只能有一个线程离开条件队列,另外两个线程将仍然需要处于等待状态,这就是在代码中使用while(conditioin is not true){this.wait();}而不使用if(condition id not true){this.wait();}的原因。另外一种情况是:同一个条件队列与多个条件谓词互相关联。这个时候,当调用此条件队列的notifyAll()方法时,某些条件谓词根本就不会变成真。

剖析Synchronzer

ReentrantLock和Semaphore这两个接口有很多共同点。这些类都扮演了“阀门”的角色,每次只允许有限数目的线程通过它。它们的实现都使用到一个共同的基类,AbstractQueuedSynchronizer(AQS),包括CountDownLatch,ReentrancReadWriteLock,SynchronousQueue和FutureTask。AQS解决了实现一个Synchronizer的大量细节,比如等待线程的FIFO队列。
一个基于AQS的Synchronizer所执行的基本操作,是一些不同形式的获取(acquire)释放(release)。为了让一个类具有状态依赖性,它必须拥有一些状态。同步类中有一些状态要管理,AQS管理一个关于状态信息的单一整数,状态信息可以通过protected类型的getState,setState和compareAndSetState等方法进行操作。获取操作可能是独占的,像ReentrantLock一样,也可以是非独占的,像Semaphore和CountDownLatch一样。
AQS中 维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。

原子变量与非阻塞同步机制

这是java.util.concurrent包中的许多类,例如Semephore和ConcurrentLinkedQueue都提供了比使用synchronized更好的性能和可伸缩性。近来很多算法的研究都聚焦在非阻塞算法(nonblocking algorithms)上,这种算法是用低层原子化的机器指令取代锁,比如比较并交换(compare-and-swap),保证数据在并发访问下的一致性。Java5中的原子变量(atomic variable classes)都能够高效构建非阻塞算法。原子变量提供了volatile类型变量相同的内存语义,同时还支持额外的原子更新。

posted @ 2020-05-25 11:22  陈晨_软件五千言  阅读(251)  评论(0编辑  收藏  举报