(1)并发编程领域可以抽象成三个核心问题:分工、同步(协作,一个线程执行完了一个任务,如何通知执行后续任务的线程开工)和互斥(线程安全,同一时刻,只允许一个线程访问共享变量)。
分工方法:Java SDK 并发包里的 Executor、Fork/Join、Future、生产者 - 消费者、Thread-Per-Message、Worker Thread 模式
协作方法:Java SDK 并发包里的 Executor、Fork/Join、Future、CountDownLatch、CyclicBarrier、Phaser、Exchanger
线程协作问题:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行
Java 并发编程领域,解决协作问题的核心技术是管程,管程还能解决互斥问题,管程是解决并发问题的万能钥匙。
分工、同步主要强调的是性能,实现互斥的核心技术就是锁,锁解决了安全性问题,但同时也带来了性能问题
如何保证安全性的同时又尽量提高性能呢?可以分场景优化(ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能),可以使用无锁的数据结构(原子类),以及不共享变量或者变量只允许读(Thread Local 和 final 关键字,还有一种Copy-on-write 的模式)
并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。导致不确定的主要源头是可见性问题、有序性问题和原子性问题
Java 语言引入了内存模型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。
解决线程安全问题的核心方案还是互斥。解决协作问题的核心技术是管程。
(2)一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性
缓存导致的可见性问题
一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
线程切换带来的原子性问题
有序性指的是程序按照代码的先后顺序执行。
编译优化带来的有序性问题
(3) 解决可见性、有序性最直接的办法就是按需禁用缓存以及编译优化。
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile(告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入,即禁用缓存以及编译优化)、synchronized 和 final (这个变量生而不变,可以可劲儿优化)三个关键字,以及六项 Happens-Before 规则。
Happens-Before的7个规则:
(1).程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
(2).管程锁定规则(针对synchronized):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
(3).volatile变量规则:对一个volatile变量的读写操作先行发生于后面对这个变量的读写读操作,这里的"后面"同样是指时间上的先后顺序。
(4).线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
(5).线程终止(等待)规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作(对共享变量的操作)
(6).线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
(7).对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
Happens-Before的1个特性:传递性。
(4)同一时刻只有一个线程执行称之为互斥。
如果我们能够保证对共享变量的修改是互斥的,那就能保证原子性了。
Java 语言提供的锁技术:synchronized
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X,即X.class;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
当修饰代码块的时候,锁定了一个 obj 对象。
锁模型
受保护资源和锁之间的关联关系是 N:1 的关系,说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源
保护没有关联关系的多个资源
细粒度锁:用不同的锁对受保护资源进行精细化管理,能够提升性能
保护有关联关系的多个资源
锁能覆盖所有受保护资源
不能用可变对象做锁
当锁要覆盖所有受保护资源,锁的粒度太大,性能太差时,需要将锁粒度变细,这时会出现死锁(一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。)
如何预防死锁(用细粒度锁来锁定多个资源时,要注意死锁的问题。预防死锁主要是破坏三个条件中的一个,在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案)
只有以下这四个条件都发生时才会出现死锁:
(1)互斥,共享资源 X 和 Y 只能被一个线程占用;
(2)占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
(3)不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
(4)循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2等待线程 T1 占有的资源,就是循环等待。
只要我们破坏其中一个,就可以成功避免死锁的发生。第一个条件不能破坏,后三个条件都是有办法破坏掉的,到底如何做呢?
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。(增加资源管理员,同时申请资源 apply() 和同时释放资源 free()。资源管理员 被创建时使用单例模式,当不满足条件时用死循环的方式来循环等待或者利用等待通知机制来实现)
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环等待了
在破坏占用且等待条件的时候,利用死循环的方式来循环等待,太消耗 CPU 了,最好的方案应该等待 - 通知机制。
一个完整的等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁
等待 - 通知机制可以有多种实现方式
synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。
同一时刻,只允许一个线程进入 synchronized 保护的临界区,当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。(wait() 什么时候返回 notify什么时候返回,即执行结束????)
当条件满足时调用 notify(),会通知右侧等待队列中的线程,告诉它条件曾经满足过。notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。
解决上面提到的条件曾经满足过这个问题,用范式 while(条件不满足) { wait(); }
两个等待队列是不同的
wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,被调用的前提是已经获取了相应的互斥锁,在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。(wait() 什么时候返回 ,即执行结束???? 在被notify唤醒之后就执行结束返回吗? 还是在notify之后对应线程重新获取相应的互斥锁时执行结束返回? notify什么时候返回 ,即执行结束???notify在通知线程之后会返回,并释放互斥锁???)
在这个等待 - 通知机制中,我们需要考虑以下四个要素:互斥锁 线程要求的条件 何时等待 何时通知
尽量使用 notifyAll() ????? (一个notify对应一个wait,浪费一个wait,自然有一个永远失去机会,获取或竞争互斥锁的规则是什么?和资源有什么关系吗?)
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。
使用 notify()需要满足以下三个条件:
所有等待线程拥有相同的等待条件; 所有等待线程被唤醒后,执行等待线程被唤醒后,执行相同的操作;只需要唤醒一个线程。
wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?
wait()方法会释放对象的“锁标志”。当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用了notify()方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池中,只有锁标志等待池中的线程可以获取锁标志,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()方法,会将对象等待池中的所有线程都移动到该对象的锁标志等待池。
sleep()方法需要指定等待的时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,该方法既可以让其他同优先级或者高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行机会。但是sleep()方法不会释放“锁标志”,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。
并发编程中我们需要注意的问题分别是:安全性问题、活跃性问题和性能问题。
安全性问题(什么是线程安全)本质上就是正确性,而正确性的含义就是程序按照我们期望的执行,不要让我们感到意外。
理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。
要认真分析存在线程安全问题的情况是: 共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据
数据竞争(Data Race,当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的)
竞态条件(Race Condition),指的是程序的执行结果依赖线程执行的顺序。或者程序的执行依赖于某个状态变量,当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。
面对数据竞争和竞态条件问题,又该如何保证线程的安全性?用互斥这个技术方案,统一归为:锁
活跃性问题,指的是某个操作无法执行下去。分为死锁,“活锁”和“饥饿”
发生“死锁”后线程会互相等待,而且会一直等待下去,线程永久地“阻塞”了
“活锁”:线程虽然没有发生阻塞,但仍然会存在执行不下去的情况。解决“活锁”的方案是谦让时,尝试等待一个随机的时间就可以了。
“活锁”的情况:多个线程类似死锁的情况下,同时释放掉自己已经获取的资源,然后同时获取另外一种资源,又形成依赖循环,导致都不能执行下去。就是同时放弃,然后又重试竞争,最后死循环在里面了。
“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。解决“饥饿”问题的方案:一是保证资源充足,二是公平地分配资源(使用公平锁,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源),三就是避免持有锁的线程长时间执行。
使用锁的时候一定要关注对性能的影响。第一,最好的方案自然就是使用无锁的算法和数据结构了。第二,减少锁持有的时间。他们相关的实现技术是什么?
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。就是管理类的成员变量和成员方法,让这个类是线程安全的。在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
管程解决互斥问题就是将共享变量及其对共享变量的操作统一封装起来,对共享变量的操作要保证互斥。
管程如何解决线程间的同步问题
入口等待队列,当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
条件变量和等待队列的作用就是解决线程同步问题。当线程 T1 执行条件不满足时就去条件变量对应的等待队列里面等。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。
用面向对象思想写好并发程序,从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。
并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。
并发程序的一个核心问题,是解决多线程同时访问共享变量的问题。面向对象思想,对共享变量的访问路径可以轻松把控。
利用面向对象思想写并发程序的思路:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共...
在设计阶段,我们一定要识别出所有共享变量之间的约束条件(反映在代码里,基本上都会有 if 语句),如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。
制定并发访问策略,从方案上来看,无外乎就是以下“三件事”。
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
不变模式:这个在 Java 领域应用的很少,
管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。
Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。
Java 语言本身提供的synchronized 也是管程的一种实现,两者区别在哪里呢?
synchronized 没有办法解决死锁问题的破坏不可抢占条件,重新设计一把互斥锁去解决这个问题,有三种方案:
能够响应中断(当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁,什么时候发送中断信号???谁发送中断信号???应该是其他线程调用线程 A 的 interrupt() 方法。synchronized 获取隐式锁失败进入阻塞状态,阻塞态的线程不响应中断信号,并发包里的锁能够在阻塞态的线程响应中断。当线程 A 处于 WAITING、TIMED_WAITING状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。使用Lock 的lockInterruptibly时,线程在请求lock并被阻塞时,如果被interrupt,则“此线程会被唤醒并被要求处理InterruptedException”,而synchronized 获取隐式锁失败进入阻塞状态,阻塞态的线程不响应中断信号)。
支持超时(如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁)。
非阻塞地获取锁(如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁)。
Lock 接口的三个方法可实现上面三种方案。(lockInterruptibly,tryLock(long time, TimeUnit unit),tryLock())
Java SDK 并发包里的 Lock 有别于 synchronized 隐式锁的三个特性:能够响应中断、支持超时和非阻塞地获取锁。
Java SDK的 Lock可以保证可见性
ReentrantLock 可重入锁(线程可以重复获取同一把锁。)
可重入函数,指的是多个线程可以同时调用该函数,是线程安全的。
公平锁(唤醒的策略就是谁等待的时间长,就唤醒谁)与非公平锁(不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。场景如下:线程释放锁之后,如果来了一个线程获取锁,他不必去排队直接获取到,获取不到才进队列)
推荐的三个用锁的最佳实践:永远只在更新对象的成员变量时加锁 永远只在访问可变的成员变量时加锁 永远不在调用其他对象的方法时加锁
另外:减少锁的持有时间、减小锁的粒度等
来源:oschina
链接:https://my.oschina.net/u/4382640/blog/4470198