Java 并发编程笔记1:基本概念

1、并发和并行的区别?

看看大神怎么说的:Pob Pike:concurrency-is-not-parallelism

In programming, concurrency is the composition of independently executing processes, while parallelism is the simultaneous execution of (possibly related) computations.

Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.

并发处于问题域,程序需要处理多个同时(或几乎同时)的事件。
并行处于解决方案域,通过并行处理问题的不同部分来使程序更快。

并发程序具有多个逻辑控制线程,这些线程可以并行运行,也可以不并行运行。
并行程序通过同时(并行地)执行计算的不同部分,达到比顺序程序更快地运行效果。它可能有,也可能没有多个逻辑控制线程。

从任务调度和执行的角度来看:

并发:在一个单核CPU上执行多个任务(线程)时,操作系统的任务调度器将程序执行从一个任务快速切换到另一个任务,由于切换很快,所以看起来像是同时进行的。

并行:有多个任务真正地同时在运行,他们有可能运行在不同的计算机、不同的CPU或者不同的CPU核心上。

从任务本身的角度来看:

并发:多个不同的(或不相关)任务同时进行
并行:一个任务的不同实例同时运行,分别操作数据集的不同部分

2、同步

同步是指协调两个或多个并发任务的执行,以得到我们预想的正确结果。

同步分为两种:

  • 控制同步:例如,当一个任务依赖于另一个任务的结束时,第二个任务在第一个任务完成之前无法启动
  • 数据访问同步:当两个或多个任务可以访问共享变量时,在特定时间内只允许一个任务访问该变量

与同步相关的一个概念是临界区。

临界区是一段代码,由于它可以访问共享资源,因此在任何给定时间只能由一个任务执行。

在并发系统中有不同的机制来获得同步,最常见的有两种:

  • 信号量(Semaphore):信号量是对一个或多个资源的访问控制机制。信号量用一个变量存储可以访问的资源数量,并提供两个原子操作来管理这个变量值。互斥(mutual exclusion)是一种特殊的信号量,变量值只有两个(表示资源可用和不可用),并且只有设置为“不可用”的那个线程可以释放资源。
  • 监视器(Monitor):监视器是一种通过共享资源实现互斥的机制。监视器有一个互斥锁,一个条件变量,以及两个操作(等待条件和发出条件信号)。 一旦发出信号,只有其中一个等待它的任务能继续执行。Java中的对象都实现了监视器。

3、并发编程可能碰到的问题

竞争条件(race condition)

多个任务同时在临界区外对共享变量执行写入操作,由于在临界区外,也就是说没有任何同步机制,最终的结果取决于这些任务执行的先后顺序。

死锁(deadlock)

两个(或多个)任务相互等待对方释放一个共享资源,结果就是谁都没能获得对方的资源而一直等待下去。死锁发生必须满足四个条件同时发生(Coffman’s conditions) :

  • 互斥(Mutual exclusion):资源不可共享,同时只能被一个任务获取。
  • 保持和等待条件(Hold and wait condition):任务已经获取了一个互斥的资源,并且正在请求获取另一个互斥的资源,所以它在等待,并且不会释放已经获取的那个资源。
  • 不存在资源抢占(No pre-emption):资源只能被保持它的任务释放。
  • 循环等待(Circular wait):任务1正在等待任务2持有的资源,任务2正在等待任务3持有的资源,依此类推,直到任务n等待任务1持有的资源。

解决死锁的方法:

  • 忽略死锁:这是最常用的机制。不做任何防备措施,如果发生死锁,重启应用。
  • 检测死锁:用一个特殊的任务来检测是否发生了死锁。 如果检测到就采取相关措施修复,例如强制完成一项任务或强制解放资源。
  • 禁止死锁:在程序设计时,禁止死锁的必要条件,即禁止Coffman’s conditions同时发生。
  • 避免死锁:如果您在任务开始执行之前有关于任务使用的资源的信息,则可以避免死锁。当任务想要开始执行时,您可以分析系统中可用的资源以及任务所需的资源,由任务决定它是否可以开始执行。

活锁(livelock)

有两个由于另一个的操作而总是更改其状态的任务时,就可能会发生活锁。它们处于状态变化的循环中,无法继续执行真正的业务逻辑。

例如,有两个任务:任务1和任务2,并且它们都需要两个资源:资源1和资源2。假设任务1在资源1上有锁,而任务2在资源2上有锁。因为它们无法访问他们需要的资源,它们决定释放资源并再次开始循环。这种情况可以无限期地继续下去,因此任务永远不会结束执行。

饥饿(Resource starvation)

饥饿是指一个任务永远获取不到它想要的资源。例如有多个任务都需要获取一个资源,当这个资源被释放之后,由系统来决定哪个任务可以获得这个资源,如果调度算法不好,就可能有线程一直都没被调度而产生饥饿。

公平锁可以解决饥饿。等待资源的所有线程都有机会获取锁。一种实现是根据线程等待时间来选择下一个获取锁的线程。公平锁需要额外的开销,可能会降低程序吞吐量。

优先级倒置(Priority inversion)

优先级倒置是指当低优先级任务拥有高优先级任务所需的资源时,低优先级任务会在高优先级任务之前完成其执行。

4、并发算法设计的方法论

通过以下步骤将顺序执行的算法并行化:
在这里插入图片描述
步骤1:分析(analysis)

分析算法的顺序版本,找到可以并行执行的代码部分。应特别关注那些执行时间长和代码密集的部分,优化这部分才能带来性能的显著提升。

循环就是这种应该特别关注的地方。在循环中,一个步骤独立于其它步骤,或者一部分代码独立于其它部分的代码,就可以优化成并行执行。例如应用初始化时,涉及到创建数据库连接,加载配置文件,初始化一些全局对象,这些步骤都是彼此独立的,可以并行执行。

步骤2:设计(design)

如何将步骤1找到的代码部分并行化。修改会影响到应用的两个方面:代码结构和数据结构,相应的有两种设计思路。

  • 任务分解(Task decomposition):将代码分割成两个或多个可以并行执行的子任务。可能一些任务必须按照一定顺序执行,或者在某个时间点等待,这时就需要任务同步。
  • 数据分解(Data decomposition):当有多个任务操作数据集的一部分的时候,可以使用数据分解。将这个数据集共享并使用临界区(或同步)保护多个任务对数据集的访问。

设计是一个在各个方面权衡的过程,在保证正确性的前提下,还需要保持性能、简洁性、可移植性和可扩展性的平衡。

步骤3:实现(implementation)

使用编程语言实现并行算法,如果有必要,还使用线程库。

步骤4:调试(debugging)和测试(testing)

实现算法之后一定要测试,可以用顺序化算法的结果检验正确性。

步骤5:优化(tuning)

最后一步就是比较并行化版本与顺序版本的吞吐量。如果结果达不到预先需求,则需要review代码,找到性能不足的原因,这可能是算法设计不合理,也有可能是参数设置没有达到最优,需要反复测试。

经常使用的一些性能指标:

  • 速度提升(Speedup): 顺序算法执行时间/并行算法执行时间 。

  • 阿姆达尔定律(Amdahl’s law):用于计算并行化最大性能收益。
    在这里插入图片描述
    P表示可以并行化的代码所占的百分比,N表示执行算法的CPU核数。

  • Gustafson-Barsis’ law:阿姆达尔定律有其局限性。它假设增加CPU核数时输入数据集不变。但通常我们增加CPU核实为了处理更多的数据。

     			Speedup = N - P(1 - P)*(N - P)
    

5、Java 并发编程概览

基础并发类:

  • Thread类,Java中的线程实现
  • Runnable接口,定义一个任务,只有一个方法,void run()
  • ThreadLocal类,将变量保存线程本地,而不是共享
  • ThreadFactory接口, 线程创建工厂类

同步相关:

第一类:定义临界区访问共享变量(即互斥访问)

  • synchronized 关键字,将一个代码块或整个方法定义成一个临界区
  • Lock接口,比synchronized关键字更灵活的同步方案,ReentrantLock、ReentrantReadWriteLock、StampedLock等。

第二类:在关键点同步多个任务的执行

  • Semaphore类:标准信号量机制的Java实现
  • CountDownLatch类:允许任务等待多个操作完成之后再进行
  • CyclicBarrier类:允许任务同步到某个关键点
  • Phaser类:允许将任务执行分解成一系列状态,在所有任务完成当前状态之前,任何任务都不能进行下一个状态。

线程管理相关(Executors):

  • Executor和ExecutorService接口:定义了很多线程管理的方法
  • ThreadPoolExecutor类:具有线程池的Executor实现类
  • ScheduledThreadPoolExecutor类:具有调度功能(延迟执行和定期执行等)的Executor
  • Executors类:功能类,便于创建Executor
  • Callable接口:类似于Runnable接口,另一种任务定义,其方法有返回值,而Runnable的没有返回值
  • Future接口:用于获取和管理Callable返回的值

Fork/Join

The Fork/Join framework defnes a special kind of executor specialized in the resolution of problems with the divide and conquer technique.

  • ForkJoinPool类:实现运行任务的Executor
  • ForkJoinTask类:需要在ForkJoinPool中执行的任务
  • ForkJoinWorkerThread:在ForkJoinPool中执行任务的线程

并行流(Parallel streams)

  • Stream接口:定义了所有的流操作
  • Optional类:一个可以为null的容器对象,要么为某个值要么为null
  • Collectors类: 实现了很多reduce操作(reduction operations),reduce操作也是流的终止操作。
  • Lambda表达式:大多数流方法都有一个lambda表达式,lambda表达式是Java实现Functional接口的优雅的方式。

并发数据结构

并发数据结构分为两种:阻塞式和非阻塞式。

  • 阻塞式数据结构包含阻塞的方法,例如,当容器为空时get一个元素会一直阻塞
  • 非阻塞式数据结构所有方法都立即执行,不会阻塞。例如,当容器为空时get一个元素立即返回null或抛出异常。

常见的并发数据结构有:

ConcurrentLinkedDeque: 非阻塞列表
ConcurrentLinkedQueue: 非阻塞队列
LinkedBlockingDeque: 阻塞列表
LinkedBlockingQueue: 阻塞队列
PriorityBlockingQueue: 阻塞优先队列
ConcurrentSkipListMap: 非阻塞的 navigable map
ConcurrentHashMap: 非阻塞的HashMap
AtomicBoolean, AtomicInteger, AtomicLong, and AtomicReference: 原子数据类型

6、并发设计模式

信号模式(Signaling)

当一个任务需要通知另一个任务时,可以使用信号模式。可以使用信号量或互斥量来实现该模式。

Java中的选择有:

  • ReentrantLock
  • Semaphore
  • Object类的wait()和notify()方法

例如:

public void task1() {
	section1();
	commonObject.notify();
}
public void task2() {
	commonObject.wait();
	section2();
}

集合点模式(Rendezvous)

该模式是信号模式的更一般的形式,当第一个任务等待第二个任务的一个事件通知,同时,第二个任务也在在等待第一个任务的一个事件通知,就可以使用该模式。

// section2_2()总是会在section1_1()之后执行
// section1_2()总是会在section2_1()之后执行
// 如果在调用notify()方法之前调用wait()方法, 就会发生死锁
public void task1() {
	section1_1();
	commonObject1.notify();
	commonObject2.wait();
	section1_2();
}
public void task2() {
	section2_1();
	commonObject2.notify();
	commonObject1.wait();
	section2_2();
}

互斥

不解释,直接上示例:

public void task() {
preCriticalSection();
	lockObject.lock() // The critical section begins
	criticalSection();
	lockObject.unlock(); // The critical section ends
	postCriticalSection();
}

Multiplex
不解释,就是互斥的一般形式,即指定个数的任务可以同时执行临界区。

public void task() {
	preCriticalSection();
	semaphoreObject.acquire(); //其内部变量值-1
	criticalSection();
	semaphoreObject.release();//其内部变量值+1
	postCriticalSection();
}

栅栏模式(Barrier)

此模式用于多个任务的同步,只有当所有任务都到达同步点后,任务才能继续执行。

public void task() {
	preSyncPoint();
	barrierObject.await();//阻塞,知道所有任务到达同步点
	postSyncPoint();
}

双重检查锁

public class Singleton{
	private Object reference;
	private Lock lock=new ReentrantLock();
	
	public Object getReference() {
		if (reference==null) {
			lock.lock();
			try {
				if (reference == null) {
					reference=new Object();
				}
			} finally {
				lock.unlock();
			}
		}
		return reference;
	}
}

当然,最好的单例实现是:

public class Singleton {

	private static class LazySingleton {
		private static final Singleton INSTANCE = new Singleton();
	}
	
	public static Singleton getSingleton() {
		return LazySingleton.INSTANCE;
	}
}

读写锁
线程池
线程本地存储

7、Java 内存模型

内存模型描述了各个任务如何通过内存相互交互,以及一个任务所做的更改何时对另一个任务可见。它还包括允许哪些代码优化以及在什么情况下才能代码优化。

Java内存模型的主要目标是,使得正确编写的并发应用程序在每个Java虚拟机(JVM)上正常运行,不管底层是什么操作系统,CPU架构以及CPU核数。

Java内存模型(Java Memory Model)简述如下:

  • 定义了volatile,synchronized和final关键字的行为。
  • 确保正确同步的并发程序在所有CPU架构上正确运行。
  • 定义了 happens-before规则。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。具体规则如下:
    • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
    • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
    • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
    • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
    • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
    • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
    • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
    • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
  • 当线程获得监视器,它的内存缓存全部无效
  • 当线程释放监视器,它的内存缓存会刷新进主内存
  • Java内存模型对开发者不可见

猜你喜欢

转载自blog.csdn.net/wen524/article/details/88114045