006-多线程-基础-同步解决 概述【ReentrantLock、Semaphore、CyclicBarrier、CountDownLatch】
一、解决方案
004-线程同步问题引出、同步问题解决、死锁、生产者与消费者
通过以上文章可知,通过原子性AtomicLong 、以及内部锁(synchronized)机制可以解决线程安全问题。以下是一些高级用法。
1、回顾synchronized :
核心类库包含一个 Thread 类,可以用它来构建、启动和操纵线程,Java 语言包括了跨线程传达并发性约束的构造 。
synchronized 和 volatile 。在简化与平台无关的并发类的开发的同时,它决没有使并发类的编写工作变得更繁琐,只是使它变得更容易了。
把代码块声明为 synchronized,有两个重要后果,通常是指该代码具有 原子性(atomicity)和 可见性(visibility)。原子性意味着一个线程一次只能执行由一个指定监控对象(lock)保护的代码,从而防止多个线程在更新共享状态时相互冲突。可见性则更为微妙;它要对付内存缓存和编译器优化的各种反常行为。一般来说,线程以某种不必让其他线程立即可以看到的方式(不管这些线程在寄存器中、在处理器特定的缓存中,还是通过指令重排或者其他编译器优化),不受缓存变量值的约束,但是如果开发人员使用了同步,如下面的代码所示,那么运行库将确保某一线程对变量所做的更新先于对现有 synchronized 块所进行的更新,当进入由同一监控器(lock)保护的另一个 synchronized 块时,将立刻可以看到这些对变量所做的更新。类似的规则也存在于 volatile 变量上。
所以,实现同步操作需要考虑安全更新多个共享变量所需的一切,不能有争用条件,不能破坏数据(假设同步的边界位置正确),而且要保证正确同步的其他线程可以看到这些变量的最新值。通过定义一个清晰的、跨平台的内存模型(该模型在 JDK 5.0 中做了修改,改正了原来定义中的某些错误),通过遵守下面这个简单规则,构建“一次编写,随处运行”的并发类是有可能的:不论什么时候,只要您将编写的变量接下来可能被另一个线程读取,或者您将读取的变量最后是被另一个线程写入的,那么您必须进行同步。
synchronized (lockObject) { // update object state }
不过现在好了一点,在最近的 JVM 中,没有争用的同步(一个线程拥有锁的时候,没有其他线程企图获得锁)的性能成本还是很低的。(也不总是这样;早期 JVM 中的同步还没有优化,所以让很多人都这样认为,但是现在这变成了一种误解,人们认为不管是不是争用,同步都有很高的性能成本。)
同步缺陷,它无法中断一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。同步还要求锁的释放只能在与获得锁所在的堆栈帧相同的堆栈帧中进行,多数情况下,这没问题(而且与异常处理交互得很好),但是,确实存在一些非块结构的锁定更合适的情况。
2、第三种 重进入(ReentrantLock)
重进入是一种基于per-thread的机制,并不是一种独立的同步方法 。基本实现是这样的:每个锁关联一个请求计数器和一个占有它的线程,当计数器为0时,锁是未被占有的,线程请求时,JVM将记录锁的占有者,并将计数器增1,当同一线程再次请求这个锁时,计数器递增;线程退出时,计数器减1,直到计数器为0时,锁被释放。
可见性和过期数据
可见性,可以说是一种原始概念,并不是一种单独的同步方法,就是说,同步可以实现数据的可见性,和避免过期数据的出现。关于可见性方面,同步机制看下面的Volatile变量。
java.util.concurrent.lock 中的 Lock 框架是锁定的一个抽象,它允许把锁定的实现作为 Java 类,而不是作为语言的特性来实现。这就为Lock 的多种实现留下了空间,各种实现可能有不同的调度算法、性能特性或者锁定语义。 ReentrantLock 类实现了 Lock ,它拥有与synchronized 相同的并发性和内存语义,但是添加了类似轮询锁、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。(换句话说,当许多线程都想访问共享资源时,JVM 可以花更少的时候来调度线程,把更多时间用在执行线程上。)
reentrant 锁意味着什么呢?简单来说,它有一个与锁相关的获取计数器,如果拥有锁的某个线程再次得到锁,那么获取计数器就加1,然后锁需要被释放两次才能获得真正释放。这模仿了 synchronized 的语义;如果线程进入由线程已经拥有的监控器保护的 synchronized 块,就允许线程继续进行,当线程退出第二个(或者后续) synchronized 块的时候,不释放锁,只有线程退出它进入的监控器保护的第一个 synchronized 块时,才释放锁。
显示锁
显示锁表面意思就是显示的调用锁,且释放锁。它提供了与synchronized基本一致的机制。但是有synchronized不能达到的效果,如:定时锁的等待、可中断锁的等待、公平性、及实现非块结构的锁。但是为什么还用synchronized呢?其实,用显示锁会比较复杂,且容易出错,如下面的代码:
Lock lock = new ReentrantLock(); ... lock.lock(); try{ ... }finally{ lock.unlock(); }
可以看到 Lock 和 synchronized 有一点明显的区别 —— lock 必须在 finally 块中释放。否则,如果受保护的代码将抛出异常,锁就有可能永远得不到释放!而使用同步【内部锁synchronized】,JVM 将确保锁会获得自动释放。
除此之外,与目前的 synchronized 实现相比,争用下的 ReentrantLock 实现更具可伸缩性。(在未来的 JVM 版本中,synchronized 的争用性能很有可能会获得提高。)这意味着当许多线程都在争用同一个锁时,使用 ReentrantLock 的总体开支通常要比 synchronized 少得多。
读写锁
有的时候,数据是需要被频繁读取的,但不排除偶尔的写入,我们只要保证:在读取线程读取数据的时候,能够读到最新的数据就不会问题。此时符合读-写锁的特点:一个资源能够被多个线程读取,或者一个线程写入,二者不同时进行。这种特点,在特定的情况下有很好的性能!
Volatile变量
这是一种轻量级的同步机制,和前面说的可见性有很大关系,可以说,volatile变量,可以保证变量数据的可见性。在Java中设置变量值的操作,对于变量值的简单读写操作没有必要进行同步,都是原子操作。只有long和double类型的变量是非原子操作的。JVM将二者(long和double都是64位的)的读写划分为两个32位的操作,这样就有可能就会不安全,只有声明为volatile,才会使得64位的long和double成为线程安全的。当一个变量声明为volatile类型后,编译器会对其进行监控,保证其不会与其它内存操作一起被重排序(重排序:举个例子,num=num+1;flag=true;JVM在执行这两条语句时,不一定先执行num=num+1,也许在num+1赋值给num之前,flag就已经为true了,这就是一种重排序),同时,volatile变量不会被进行缓存,所以,每当读取volatile变量时,总能得到最新的值!为什么会这样?我们来看下面这段话:在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,只有把该变量声明为volatile,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。Java语言规范中指出:为了获得最佳速度,允许线程保存共享成员变量的私有拷贝,而且只当线程进入或者离开同步代码块时才与共享成员变量的原始值对比。这样当多个线程同时与某个对象交互时,就必须要注意到要让线程及时的得到共享成员变量的变化。
volatile关键字就是提示JVM:对于这个成员变量不能保存它的私有拷贝,而应直接与共享成员变量交互。
此处注意:volatile关键字只能保证线程的可见性,但不能保证原子性,试图用volatile保证原子性会很复杂!
一般情况,volatile关键字用于修饰一些变量,如:被当做完成标识、中断、状态等。满足一下三个条件的情况,比较符合volatile的使用情景:
1、写入变量时并不依赖变量的当前值(否则就和value++类似了),或者能够确保只有单一线程修改变量的值。
2、变量不需要与其他的状态变量共同参与不变约束。
3、访问变量时,没有其它原因需要加锁。(毕竟加锁是个耗性能的操作)
使用建议:在两个或者更多的线程访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,不必使用。由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。
Semaphore(信号量)
信号量的意思就是设置一个最大值,来控制有限个对象同时对资源进行访问。因为有的时候有些资源并不是只能由一个线程同时访问的,举个例子,我这儿有5个碗,只能满足5个人同时用餐,那么我可以设置一个最大值5,线程访问时,用acquire() 获取一个许可,如果没有就等待,用完时用release() 释放一个许可。这样就保证了最多5个人同时用餐,不会造成安全问题,这是一种很简单的同步机制。
信号量本质是ReetrantLock,信号量维护了一个信号量许可集的个数,规定了最大允许多少线程同时访问数据,通过acquire()获得许可,一个线程获得许可,信号量的可用许可减一,通过release()释放许可,释放之后可用许可加1。
如果acquire没有可用许可,那么线程会阻塞。
构造方法:Semaphore(int size)
例如:食堂的5个窗口,有10个人打饭菜,那么最大只有5人同时进行打饭菜。
循环栅栏 CyclicBarrier
是一个同步辅助类,允许一组线程等待,释放等待线程之后可重用。
CyclicBarrier(int size,Runnable runnable);
在线程内调用cyclicBarrier的await()方法,当调用该方法的线程数达到指定size个数时,等待的线程才会往下执行。
闭锁CountDownLatch
CountDownLatch(int size)
也是线程同步的辅助类,指定的一个或多个线程等待其他线程执行完成后执行。
在需要等待的线程内调用await(),该线程就会暂停执行,在被等待的线程调用countdown(),countDown计数减一,直到0,等待线程继续执行。
countDownLath无法被重置,循环栅栏可以。
循环栅栏是要执行的线程增加,countDownLatch是执行完成的线程增加。
临界区
如果有多个线程试图同时访问临界区,那么在有一个线程进入后,其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的。在使用临界区时,一般不允许其运行时间过长,只要进入临界区的线程还没有离开,其他所有试图进入此临界区的线程都会被挂起而进入到等待状态,并会在一定程度上影响程序的运行性能。尤其需要注意的是不要将等待用户输入或是其他一些外界干预的操作包含到临界区。如果进入了临界区却一直没有释放,同样也会引起其他线程的长时间等待。
同步容器
Java为我们提供非常完整的线程同步机制,这包括jdk1.5后新增的java.util.concurrent包,里面包含各种各样出色的线程安全的容器(即集合类)。如ConcurrentHashMap,CopyOnWriteArrayList、LinkedBlockingDeque等,这些容器有的在性能非常出色,也是值得我们程序员庆幸的事儿!
Collections位集合类提供线程安全的支持
对于有些非线程安全的集合类,如HashMap,我们可以通过Collections的一些方法,使得HashMap变为线程安全的类,如:Collections.synchronizedMap(new HashMap());
excutor框架
Java中excutor只是一个接口,但它为一个强大的同步框架做好了基础,其实现可以用于异步任务执行,支持很多不同类型的任务执行策略。excutor框架适用于生产者-消费者模式,是一个非常成熟的框架,此处不多讲,在后续的文章中,我会细细分析它!
事件驱动
事件驱动的意思就是一件事情办完后,唤醒其它线程去干另一件。这样就保证:1、数据可见性。在A线程执行的时候,B线程处于睡眠状态,不可能对共享变量进行修改。2、互斥性。相当于上锁,不会有其它线程干扰。常用的方法有:sleep()、wait()、notify()等等。
参考:《JAVA CONCURRENCY IN PRACTICE》 Brian Goetz 著
条件变量
根类 Object 包含某些特殊的方法,用来在线程的 wait() 、 notify() 和 notifyAll() 之间进行通信。这些是高级的并发性特性,许多开发人员从来没有用过它们 —— 这可能是件好事,因为它们相当微妙,很容易使用不当。幸运的是,随着 JDK 5.0 中引入 java.util.concurrent,开发人员几乎更加没有什么地方需要使用这些方法了。
通知与锁定之间有一个交互 —— 为了在对象上 wait 或 notify ,您必须持有该对象的锁。就像 Lock 是同步的概括一样, Lock 框架包含了对wait 和 notify 的概括,这个概括叫作 条件(Condition) 。 Lock 对象则充当绑定到这个锁的条件变量的工厂对象,与标准的 wait 和notify 方法不同,对于指定的 Lock ,可以有不止一个条件变量与它关联。这样就简化了许多并发算法的开发。例如, 条件(Condition) 的 Javadoc 显示了一个有界缓冲区实现的示例,该示例使用了两个条件变量,“not full”和“not empty”,它比每个 lock 只用一个 wait 设置的实现方式可读性要好一些(而且更有效)。 Condition 的方法与 wait 、 notify 和 notifyAll 方法类似,分别命名为 await 、 signal 和 signalAll,因为它们不能覆盖 Object 上的对应方法。
公平锁和不公平锁
如果查看 Javadoc,您会看到, ReentrantLock 构造器的一个参数是 boolean 值,它允许您选择想要一个 公平(fair)锁,还是一个 不公平(unfair)锁。公平锁使线程按照请求锁的顺序依次获得锁;而不公平锁则允许直接获取锁,在这种情况下,线程有时可以比先请求锁的其他线程先得到锁。
为什么我们不让所有的锁都公平呢?毕竟,公平是好事,不公平是不好的,不是吗?(当孩子们想要一个决定时,总会叫嚷“这不公平”。我们认为公平非常重要,孩子们也知道。)在现实中,公平保证了锁是非常健壮的锁,有很大的性能成本。要确保公平所需要的记帐(bookkeeping)和同步,就意味着被争夺的公平锁要比不公平锁的吞吐率更低。作为默认设置,应当把公平设置为 false ,除非公平对您的算法至关重要,需要严格按照线程排队的顺序对其进行服务。
那么同步又如何呢?内置的监控器锁是公平的吗?答案令许多人感到大吃一惊,它们是不公平的,而且永远都是不公平的。但是没有人抱怨过线程饥渴,因为 JVM 保证了所有线程最终都会得到它们所等候的锁。确保统计上的公平性,对多数情况来说,这就已经足够了,而这花费的成本则要比绝对的公平保证的低得多。所以,默认情况下 ReentrantLock 是“不公平”的,这一事实只是把同步中一直是事件的东西表面化而已。如果您在同步的时候并不介意这一点,那么在 ReentrantLock 时也不必为它担心。