-- 多线程并发适用的场景
-- 图像处理
图像处理往往拥有极大的计算量,图像的像素较大,将所有的像素遍历,需要花费较长的时间。
更有可能涉及到矩阵计算,矩阵的规模和数量将会更大。
如此密集的计算有可能超过单核CPU的计算能力,所以需要进行多线程并发处理
-- 服务端程序
-- 数据访问量较大
-- 建模需要,更适合业务场景需求
-- 实例分析
比如:一个家庭是一个进程。
其中爸爸外出工作,妈妈在家做家务,你在家写作业。
在建模的时候,肯定不会是一个线程,既是你自己,也是妈妈,同时还是爸爸。
肯定是建立三个线程
在JAVA虚拟机中,虚拟机处理要执行主线程main函数,还要做JIT(Just In Time:即时编译)编译,还需要做GC(Garbage Collection:垃圾回收)
无论是main主线程,还是JIT,还是GC都是一个单独的线程
-- 同步(Synchronous)和异步(Asynchronous)
-- 同步和异步一般情况下用来形容一次方法的调用。
-- 同步方法调用一旦开始,调用着必须等到方法调用返回之后,才能执行后续操作。
-- 异步方法更像是一个消息传递,一旦开始,方法调用会立即返回,调用者可以继续后续的行为操作。
一般不会在当前线程中进行操作,而是在另外的线程中“真实”的执行。整个过程,部位阻碍调用者的工作
-- 对于调用者来说,异步调用好像是一瞬间就完成了。如果异步调用需要返回值,那么当这个异步调用真实完成之后,则会通知调用者。
-- 实例分析
-- 同步异步就好像生活中的实体店购物和网上购物
-- 实体店购物。
去商场买电视机,当在商场看重电视机之后,就向售货员下单,然后售货员去仓库调货。
然后催促运输员进行送货,知道商家把你和电视机都送回家,购物结束。
这就类似与同步调用
-- 网购
在电脑上下订单购买电视机的时候,支付完成就标志着你的购物过程已经结束。
虽然电视机但没有送到家,但是你的任务已经完成。
商家接到订单之后,联系调配电视机,但是送货。这一切都不需要你进行操作。只要进行以下签收就行。
这类似于异步调用
-- 并发(Concurrency)和并行(Parallelism)
-- 并发侧重于多个任务交替执行
-- 并行侧重于多个任务同时执行
-- 计算机中一个CPU只能同时执行一条指令,是通过时间片轮转机制,实现并发。
-- 临界区
-- 临界区表示一种公共资源或者说是共享的数据,可以被多个线程使用。
但是每一次只能有一个线程进行使用,一旦临界区资源被占用,其他线程想要使用必须等待资源被释放。
-- 实例分析
办公室中只有一台打印机,小明和小红同时需要打印文件,如果小明执行了打印任务,那么小红就必须等到小明打印完成之后才能打印。
这里的打印机就是临界区资源
-- 在并发程序和并行程序中,临界区资源是需要保护的对象。如果不加以保护,可能打印出来损坏的文件,这肯定不是大家想要。
-- 阻塞(Blocking)和非组赛(Non-Blocking)
-- 阻塞和非阻塞通常用来形容多线程之间的相互影响。
-- 阻塞
比如一个线程占用了临界区资源,那么其他的所有需要这个资源的线程就必须在这个临界区资源进行等待,这就导致线程的挂起,这种情况就是阻塞。
此时如果占用临界区资源的线程比进行释放,那么其他所有阻塞的线程都不能进行工作。
-- 非阻塞
非阻塞的意思放好相反。强调没有一个线程可以妨碍其他线程执行。
-- 死锁(Deadlock),饥饿(Starvation),活锁(Livelock)
-- 死锁,饥饿,活锁都属于线程的活跃性问题
-- 死锁
-- 多个线程之间都互相需要其他的线程才能正常工作。
-- 饥饿
-- 1.由于线程之间优先级的问题,导致低优先级的线程一直不能获取到资源,导致不能正常执行
-- 2.一个线程一直占用临界区资源,而不进行资源的释放
-- 活锁
-- 场景描述。两个线程需要同时拥有两个资源才能执行。
-- 两个线程之间由于互相谦让,将自己的资源给对方,导致都不能同时拥有资源,导致不能正常执行。
-- 并发级别
-- 阻塞,无饥饿,无障碍,无锁,无等待
-- 阻塞(Blocking)
一个线程是阻塞的,那么当需要的资源没有被其他线程释放的时候,当前线程无法执行。
-- 使用关键字synchronized或者ReentryLock时,就是阻塞的线程。
synchronized关键字和ReentryLock都是试图在执行代码前,得到临界区资源,如果得不到,线程就会挂起等待,直到占用资源为止
-- 无饥饿(Starvation-Free)
由于线程之间优先级的问题,系统总是倾向于高优先级的线程执行。所以锁是非公平的
现在维护一个排序队列,满足先进先出(FIFO:First In First Out),所有的线程都必须排队,所有的线程都可以执行到
-- 这种情况下需要消耗资源维护排序队列,而且效果也不是太好。可以根据实际情况测试。
-- 无障碍(Obstruction-Free)
无障碍是一种最弱的非阻塞的调度方式。
多个线程如果无障碍的执行,那么就不会因为临界区资源问题,导致其中任意一个线程被挂起。
多个线程可以同时访问临界区资源。
一旦检测到临界区资源被损坏,为了确保数据的安全,当前线程会立即回滚当前的操作。
有可能会出现线程一直回滚,没有线程可以退出(正常执行完毕)。
是一种乐观的态度。如果都没有冲突可以正常的结束。
可以依赖于一个"一致性标记"来实现。线程在操作之前,先读取并保存这个标记,
在操作完成之后,再次读取,检查这个标记是否被改动过,如果改动过。
如果,两次读取一直,说明没有访问冲突问题,如果不一致,说明资源访问冲突,需要回滚。
任何需要修改数据的线程,都需要更新这个标记。表示数据已经不再安全。
-- 无锁(Lock-Free)
无锁的并发都是无障碍的。
在无锁的状态下,所有的线程都能尝试对临界区资源进行访问。但是不同的是,无锁的并发保证必然有一个线程能够在有限的步骤内完成操作并退出临界区。
在无锁的调用中,一个典型的特点是可能会包含一个无穷循环。
在这个循环中,线程会不断的尝试修改临界区资源。如果没有冲突,修改成功,那么程序退出,否则继续尝试修改
但无论如何,无锁的并发,总能保证有一个线程能够正常退出,不至于全军覆没。
至于临界区资源竞争失败,他们必须不断的尝试,知道获取到临界区资源。
如果运气不好,则会出现类似于饥饿的现象,线程会停止。
下面是一段无锁的示意代码,如果修改不成功,那么循环永远不会停止。
while(!atomicVar.compareAndSet(localVar, localVar + 1)) {
localVar = atomicVar.get();
}
-- 无等待(Wait-Free)
无锁,实在无障碍的基础上只要求有一个线程可以在有限步内完成操作正常退出,而无等待则在无锁的基础上更进一步。
要求所有的线程都要在有限步内完成,这样就不会引起饥饿问题。
如果限制这个步骤的上限,还可以分为【有界无等待】和【线程数无关的无等待】等几种,他们之间的区别只是对循环次数的限制不同。
一种典型的无等待的结构是RCU(Read Copy Update)。它的基本思想是,对数据的读操作可以不加控制。
因此,所有的读操作都是无等待的,他们不会被锁等待也不会引起数据的冲突。
但是在写数据的时候,先取得原始数据的副本,接着只修改副本数据(这就是读操作没有限制的原因),在修改完成之后,在合适的时机写回数据。
-- 并发定律
-- 并发的目的
-- 1.系统建模的需要
-- 2.获得更好的性能
-- Amdahl定律
-- 定义了串行系统并行化之后的加速比的计算公式和理论上限。
-- 加速比 = 优化前系统耗时 / 优化后系统耗时。加速比越高说明并行化之后效果越明显。
-- 根据【串行化比例】,【处理器个数】推导出【加速比】的公式
-- 加速比 = 1 / (F + (1 / n) * (1 - F))
其中F表示串行化的比例,n表示处理器个数。
-- 推导过程
-- 定义属性:
n:处理器个数
T1:一个处理器的时候的执行时间
Tn:n个处理器的时候的执行时间
F:串行化的比例
T1就是优化前系统耗时
n个处理器优化后的系统耗时
Tn = T1 * (F + (1 / n) * (1 - F))
加速比 = T1 / Tn
= T1 / T1 * (F + (1 / n) * (1 - F))
= 1 / (F + (1 / n) * (1 - F))
= 1 / F + (1 / n) * (1 - F)
-- 根据Amdahl定律,使用多核CPU对系统进行优化,优化的效果系统中的【串行化比例】和【CPU的数量】
-- 串行化比例越小,CPU数量越大,加速比越高
-- Gustafson定律
-- 加速比 = n - F(n - 1)
-- 推导过程
-- 属性定义
a:系统中串行执行的时间
b:系统中并行执行的时间
n:处理器个数
F:串行比例 = a / (a + b)
串行化系统耗时 = a + n * b
并行化系统耗时 = a + b
加速比 = 串行化系统耗时 / 并行化系统耗时
= (a + n * b) / (a + b)
= a / (a + b) + n * b / (a + b)
= F + n * b / (a + b)
= F + n * (a - a + b) / (a + b)
= F + n * (1 - (a / (a + b)))
= F + n * (1 - F)
= F + n - nF
= n + F(1 - n)
= n - F(n - 1)
-- 通过Amdahl定律和Gustafson定律得出如下结论
-- 在达到串行化比例之前,处理器越多,优化效率越高。
-- JMM(Java Memory Model):Java内存模型
-- 并发程序难点
并发程序比串行程序复杂的多。其中一个重要原因就是,并发程序中数据访问的一致性和安全性将会收到挑战。
如何保证一个线程访问的数据是正确的?
在串行程序中,如果读取一个变量,这个变量的值是1,那么读取到的就是1。
而在并行程序中,如果不加控制的任由线程执行,即使原来是1的值,也可能会读取到2.
因此,我们需要在深入了解并行机制的前提下,再定义一种规则,保证多个线程可以有效地,正确地协同工作。
JMM也就是为此而出现的。
JMM的关键技术点都是围绕着多线程的原子性(Atomicity), 可见性(Visibility)和有效性(Ordering)来建立的。
-- 原子性(Atomicity)
原子性指的是一个操作一旦开始,是不可中断的。
即使是在多线程的情况下,一个操作一旦开始,就不会被其它线程影响。
比如,对于一个静态的全局变量i,两个线程同时对它赋值,线程A给它赋值为1,线程B给它赋值为-1。
那么不管这两个线程以何种方式调度工作,i的值要么是1,要么是-1。
线程A和线程B之间是没有干扰的。这就是原子性的一个特点,不会被中断。
但是如果不使用int类型的数据,而是使用long类型的数据,可能就会出现不正常的数据。
因为在32位的虚拟机中,long类型的数据的读写不是原子性(Atomicity)的.(因为long数据类型是64位的)。
也就是说,在32位的虚拟机中,多个线程对long类型的数据的读写,可能会出现干扰的情况,导致数据不正确。
有可能会出现这个数据的前32位和另外一个数据的后32位组成一个新的数据。
-- 可见性(Visibility)
可见性是指当一个线程修改了某个变量的值后,其他线程能否立即知道这个修改。
对于串行程序来说,可见性的问题是不存在的。因为在任何一个步骤中修改了某个变量,在后续的步骤中,读取的肯定是修改后的值。
但是,在并行程序中,一个线程修改了某个变量的值,其他的线程未必可以马上知道这个改动。
原因
-- 1.由于编译器优化或者硬件优化,线程将变量的值缓存到cache中或者寄存器中,当一个线程修改了这个变量的时候,其他的线程还是从缓存中读取这个变量。
-- 2.指令重新排序问题。在一个线程中观察另外一个线程的变量,它们的值能否观测到,什么时候观测到,都是没有保障的。
-- 有序性(Ordering)
对于一个线程来说,它的指令的执行顺序是一定的。(要不然程序也不能正常运行)。
但是对于不同的线程来说,线程A的指令的执行顺序在线程B看来是没有保证的。
-- 指令重排
-- 指令重排的基本前提是,保证串行语义的一致性。就是说,指令重排必须保证线程内部的正常执行。
可以保证串行语义的一致性,但是没有义务保证多线程之间的语义的一致性。
-- 指令重排的目的是为了提高性能。
-- 一条指令的执行,可以简单的分为以下几步
-- 取指IF
-- 译码和取寄存器操作数的ID
-- 执行或者有效地址计算EX
-- 存储器访问MEM
-- 写回WB
-- 每个步骤涉及到的硬件可能不同
取值需要用到PC寄存器和存储器
译码会用到指令寄存器
执行会使用ALU(算术逻辑单元,是CPU的执行单元。是CPU的核心组成部分,主要用来进行二进制运算)
写回需要使用寄存器组
-- 那些指令不能进行重排:(Happen-Before原则)
-- 程序顺序原则:一个线程内部的保证语义的串行性
-- volatile规则:volatile变量的写先于读发生,这就保证了volatile变量的可见性
-- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)之前
-- 传递性:A先于B,B先于C,那么A必然先于C
-- 线程的start()方法先于它的任何一个动作
-- 线程的所有操作先于线程的终结(Thread.join())
-- 线程的中断(interrupt())先于被中断的线程的代码
-- 对象的构造函数的执行以及结束先于finalize()方法