这是Java建设者的第 110 篇原创文章html
我一直没有急于写并发的缘由是我参不透操做系统
,现在,我已经把操做系统刷了一遍,此次试着写一些并发,看看能不能写清楚,卑微小编在线求鼓励...... 我打算采起操做系统和并发同时结合讲起来的方式。java
并发历史
在计算机最先期的时候,没有操做系统,执行程序只须要一个过程,那就是从头至尾依次执行。任何资源都会为这个程序服务,这必然就会存在 浪费资源
的状况。数据库
❝这里说的浪费资源指的是资源空闲,没有充分使用的状况。编程
❞
操做系统为咱们的程序带来了 并发性
,操做系统使咱们的程序同时运行多个程序,一个程序就是一个进程,也就至关于同时运行了多个进程。缓存
操做系统是一个并发系统
,并发性是操做系统很是重要的特征,操做系统具备同时处理和调度多个程序的能力,好比多个 I/O 设备同时在输入输出;设备 I/O 和 CPU 计算同时进行;内存中同时有多个系统和用户程序被启动交替、穿插地执行。操做系统在协调和分配进程的同时,操做系统也会为不一样进程分配不一样的资源。安全
操做系统实现多个程序同时运行解决了单个程序没法作到的问题,主要有下面三点服务器
-
资源利用率
,咱们上面说到,单个进程存在资源浪费的状况,举个例子,当你在为某个文件夹赋予权限的时候,输入程序没法接受外部的输入字符,只能等到权限赋予完毕后才能接受外部输入。综合来说,就是在等待程序时没法执行其余工做。若是在等待程序的同时能够运行另外一个程序,那么将会大大提升资源的利用率。(资源并不会以为累)由于它不会划水~微信 -
公平性
,不一样的用户和程序对于计算机上的资源有着一样的使用权。一种高效的运行方式是为不一样的程序划分时间片使用资源,可是有一点须要注意,操做系统能够决定不一样进程的优先级,虽然每一个进程都有可以公平享有资源的权利,可是每次前一个进程释放资源后的同时有一个优先级更高的进程抢夺资源,就会形成优先级低的进程没法得到资源,长此以往会致使进程饥饿。多线程 -
便利性
,单个进程是没法通讯的,通讯这一点我认为实际上是一种避雷针
策略,通讯的本质就是信息交换
,及时进行信息交换可以避免信息孤岛
,作重复性的工做;任何并发能作的事情,顺序编程也可以实现,只不过这种方式效率很低,它是一种阻塞式
的。并发
可是,顺序编程(也称为串行编程
)也不是一无可取
的,串行编程的优点在于其「直观性和简单性」,客观来说,串行编程更适合咱们人脑的思考方式,可是咱们并不会知足于顺序编程,「we want it more!!!」 。资源利用率、公平性和便利性促使着进程出现的同时也促使着线程
的出现。
若是你还不是很理解进程和线程的区别的话,那么我就以我多年操做系统的经验(吹牛逼,实则半年)来为你解释一下:「进程是一个应用程序,而线程是应用程序中的一条顺序流」。
或者阮一峰
老师也给出了你通俗易懂的解释
摘自 https://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html
线程会共享进程范围内的资源,例如内存和文件句柄,可是每一个线程也有本身私有的内容,好比程序计数器、栈以及局部变量。下面汇总了进程和线程共享资源的区别
线程被描述为一种轻量级
的进程,轻量级体如今线程的建立和销毁要比进程的开销小不少。
❝注意:任何比较都是相对的。
❞
在大多数现代操做系统中,都以线程为基本的调度单位,因此咱们的视角着重放在对线程的探究。
线程
优点和劣势
合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术,若是线程使用得当,可以有效的下降程序的开发和维护成本。
在 GUI 中,线程能够提升用户界面的响应灵敏度,在服务器应用程序中,并发能够提升资源利用率以及系统吞吐率。
Java 很好的在用户空间实现了开发工具包,并在内核空间提供系统调用来支持多线程编程,Java 支持了丰富的类库 java.util.concurrent
和跨平台的内存模型
,同时也提升了开发人员的门槛,并发一直以来是一个高阶的主题,可是如今,并发也成为了主流开发人员的必备素质。
虽然线程带来的好处不少,可是编写正确的多线程(并发)程序是一件极困难的事情,并发程序的 Bug 每每会诡异地出现又诡异的消失,在当你认为没有问题的时候它就出现了,难以定位
是并发程序的一个特征,因此在此基础上你须要有扎实的并发基本功。那么,并发为何会出现呢?
为何是并发
计算机世界的快速发展离不开 CPU、内存和 I/O 设备的高速发展,可是这三者一直存在速度差别性问题,咱们能够从存储器的层次结构能够看出
CPU 内部是寄存器的构造,寄存器的访问速度要高于高速缓存
,高速缓存的访问速度要高于内存,最慢的是磁盘访问。
程序是在内存中执行的,程序里大部分语句都要访问内存,有些还须要访问 I/O 设备,根据漏桶理论来讲,程序总体的性能取决于最慢的操做也就是磁盘访问速度。
由于 CPU 速度太快了,因此为了发挥 CPU 的速度优点,平衡这三者的速度差别,计算机体系机构、操做系统、编译程序都作出了贡献,主要体现为:
-
CPU 使用缓存来中和和内存的访问速度差别
-
操做系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不一样的
CPU 时间片
可以执行不一样的任务,从而均衡这三者的差别 -
编译程序提供优化指令的执行顺序,让缓存可以合理的使用
咱们在享受这些便利的同时,多线程也为咱们带来了挑战,下面咱们就来探讨一下并发问题为何会出现以及多线程的源头是什么
线程带来的安全性问题
线程安全性是很是复杂的,在没有采用同步机制
的状况下,多个线程中的执行操做每每是不可预测的,这也是多线程带来的挑战之一,下面咱们给出一段代码,来看看安全性问题体如今哪
public class TSynchronized implements Runnable{ static int i = 0; public void increase(){ i++; } @Override public void run() { for(int i = 0;i < 1000;i++) { increase(); } } public static void main(String[] args) throws InterruptedException { TSynchronized tSynchronized = new TSynchronized(); Thread aThread = new Thread(tSynchronized); Thread bThread = new Thread(tSynchronized); aThread.start(); bThread.start(); System.out.println("i = " + i); } }
这段程序输出后会发现,i 的值每次都不同,这不符合咱们的预测,那么为何会出现这种状况呢?咱们先来分析一下程序的运行过程。
TSynchronized
实现了 Runnable 接口,并定义了一个静态变量 i
,而后在 increase
方法中每次都增长 i 的值,在其实现的 run 方法中进行循环调用,共执行 1000 次。
可见性问题
在单核 CPU 时代,全部的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决,CPU 和 内存之间
若是用图来表示的话我想会是下面这样
在多核时代,由于有多核的存在,每一个核都可以独立的运行一个线程,每颗 CPU 都有本身的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不一样的 CPU 上执行时,这些线程操做的是不一样的 CPU 缓存
由于 i 是静态变量,没有通过任何线程安全措施的保护,多个线程会并发修改 i 的值,因此咱们认为 i 不是线程安全的,致使这种结果的出现是因为 aThread 和 bThread 中读取的 i 值彼此不可见,因此这是因为 可见性
致使的线程安全问题。
原子性问题
看起来很普通的一段程序却由于两个线程 aThread
和 bThread
交替执行产生了不一样的结果。可是根源不是由于建立了两个线程致使的,多线程只是产生线程安全性的必要条件,最终的根源出如今 i++
这个操做上。
这个操做怎么了?这不就是一个给 i 递增的操做吗?也就是 「i++ => i = i + 1」,这怎么就会产生问题了?
由于 i++
不是一个 原子性
操做,仔细想一下,i++ 其实有三个步骤,读取 i 的值,执行 i + 1 操做,而后把 i + 1 得出的值从新赋给 i(将结果写入内存)。
当两个线程开始运行后,每一个线程都会把 i 的值读入到 CPU 缓存中,而后执行 + 1 操做,再把 + 1 以后的值写入内存。由于线程间都有各自的虚拟机栈和程序计数器,他们彼此之间没有数据交换,因此当 aThread 执行 + 1 操做后,会把数据写入到内存,同时 bThread 执行 + 1 操做后,也会把数据写入到内存,由于 CPU 时间片的执行周期是不肯定的,因此会出现当 aThread 尚未把数据写入内存时,bThread 就会读取内存中的数据,而后执行 + 1操做,再写回内存,从而覆盖 i 的值,致使 aThread 所作的努力白费。
为何上面的线程切换会出现问题呢?
咱们先来考虑一下正常状况下(即不会出现线程安全性问题的状况下)两条线程的执行顺序
能够看到,当 aThread 在执行完整个 i++ 的操做后,操做系统对线程进行切换,由 aThread -> bThread,这是最理想的操做,一旦操做系统在任意 读取/增长/写入
阶段产生线程切换,都会产生线程安全问题。例如以下图所示
最开始的时候,内存中 i = 0,aThread 读取内存中的值并把它读取到本身的寄存器中,执行 +1 操做,此时发生线程切换,bThread 开始执行,读取内存中的值并把它读取到本身的寄存器中,此时发生线程切换,线程切换至 aThread 开始运行,aThread 把本身寄存器的值写回到内存中,此时又发生线程切换,由 aThread -> bThread,线程 bThread 把本身寄存器的值 +1 而后写回内存,写完后内存中的值不是 2 ,而是 1, 内存中的 i 值被覆盖了。
咱们上面提到 原子性
这个概念,那么什么是原子性呢?
❝并发编程的原子性操做是彻底独立于任何其余进程运行的操做,原子操做多用于现代操做系统和并行处理系统中。
原子操做一般在内核中使用,由于内核是操做系统的主要组件。可是,大多数计算机硬件,编译器和库也提供原子性操做。
在加载和存储中,计算机硬件对存储器字进行读取和写入。为了对值进行匹配、增长或者减少操做,通常经过原子操做进行。在原子操做期间,处理器能够在同一数据传输期间完成读取和写入。这样,其余输入/输出机制或处理器没法执行存储器读取或写入任务,直到原子操做完成为止。
❞
简单来说,就是「原子操做要么所有执行,要么所有不执行」。数据库事务的原子性也是基于这个概念演进的。
有序性问题
在并发编程中还有带来让人很是头疼的 有序性
问题,有序性顾名思义就是顺序性,在计算机中指的就是指令的前后执行顺序。一个很是显而易见的例子就是 JVM 中的类加载
这是一个 JVM 加载类的过程图,也称为类的生命周期,类从加载到 JVM 到卸载一共会经历五个阶段 「加载、链接、初始化、使用、卸载」。这五个过程的执行顺序是必定的,可是在链接阶段,也会分为三个过程,即 「验证、准备、解析」 阶段,这三个阶段的执行顺序不是肯定的,一般交叉进行,在一个阶段的执行过程当中会激活另外一个阶段。
有序性问题通常是编译器带来的,编译器有的时候确实是 「好心办坏事」,它为了优化系统性能,每每更换指令的执行顺序。
活跃性问题
多线程还会带来活跃性
问题,如何定义活跃性问题呢?活跃性问题关注的是 「某件事情是否会发生」。
「若是一组线程中的每一个线程都在等待一个事件,而这个事件只能由该组中的另外一个线程触发,这种状况会致使死锁」。
简单一点来表述一下,就是每一个线程都在等待其余线程释放资源,而其余资源也在等待每一个线程释放资源,这样没有线程抢先释放本身的资源,这种状况会产生死锁,全部线程都会无限的等待下去。
换句话说,死锁线程集合中的每一个线程都在等待另外一个死锁线程占有的资源。可是因为全部线程都不能运行,它们之中任何一个资源都没法释放资源,因此没有一个线程能够被唤醒。
若是说死锁很痴情
的话,那么活锁
用一则成语来表示就是 弄巧成拙
。
某些状况下,当线程意识到它不能获取所须要的下一个锁时,就会尝试礼貌的释放已经得到的锁,而后等待很是短的时间再次尝试获取。能够想像一下这个场景:当两我的在狭路相逢的时候,都想给对方让路,相同的步调会致使双方都没法前进。
如今假想有一对并行的线程用到了两个资源。它们分别尝试获取另外一个锁失败后,两个线程都会释放本身持有的锁,再次进行尝试,这个过程会一直进行重复。很明显,这个过程当中没有线程阻塞,可是线程仍然不会向下执行,这种情况咱们称之为 活锁(livelock)
。
若是咱们指望的事情一直不会发生,就会产生活跃性问题,好比单线程中的无限循环
while(true){...} for(;;){}
在多线程中,好比 aThread 和 bThread 都须要某种资源,aThread 一直占用资源不释放,bThread 一直得不到执行,就会形成活跃性问题,bThread 线程会产生饥饿
,咱们后面会说。
性能问题
与活跃性问题密切相关的是 性能
问题,若是说活跃性问题关注的是最终的结果,那么性能问题关注的就是形成结果的过程,性能问题有不少方面:好比服务时间过长,吞吐率太低,资源消耗太高,在多线程中这样的问题一样存在。
在多线程中,有一个很是重要的性能因素那就是咱们上面提到的 线程切换
,也称为 上下文切换(Context Switch)
,这种操做开销很大。
❝在计算机世界中,老外都喜欢用 context 上下文这个词,这个词涵盖的内容不少,包括上下文切换的资源,寄存器的状态、程序计数器等。context switch 通常指的就是这些上下文切换的资源、寄存器状态、程序计数器的变化等。
❞
在上下文切换中,会保存和恢复上下文,丢失局部性,把大量的时间消耗在线程切换上而不是线程运行上。
为何线程切换会开销如此之大呢?线程间的切换会涉及到如下几个步骤
将 CPU 从一个线程切换到另外一线程涉及挂起当前线程,保存其状态,例如寄存器,而后恢复到要切换的线程的状态,加载新的程序计数器,此时线程切换实际上就已经完成了;此时,CPU 不在执行线程切换代码,进而执行新的和线程关联的代码。
引发线程切换的几种方式
线程间的切换通常是操做系统层面须要考虑的问题,那么引发线程上下文切换有哪几种方式呢?或者说线程切换有哪几种诱因呢?主要有下面几种引发上下文切换的方式
-
当前正在执行的任务完成,系统的 CPU 正常调度下一个须要运行的线程
-
当前正在执行的任务遇到 I/O 等阻塞操做,线程调度器挂起此任务,继续调度下一个任务。
-
多个任务并发抢占锁资源,当前任务没有得到锁资源,被线程调度器挂起,继续调度下一个任务。
-
用户的代码挂起当前任务,好比线程执行 sleep 方法,让出CPU。
-
使用硬件中断的方式引发上下文切换
线程安全性
在 Java 中,要实现线程安全性,必需要正确的使用线程和锁,可是这些只是知足线程安全的一种方式,要编写正确无误的线程安全的代码,其核心就是对状态访问操做进行管理。最重要的就是最 共享(Shared)
的 和 可变(Mutable)
的状态。只有共享和可变的变量才会出现问题,私有变量不会出现问题,参考程序计数器
。
对象的状态能够理解为存储在实例变量或者静态变量中的数据,共享意味着某个变量能够被多个线程同时访问、可变意味着变量在生命周期内会发生变化。一个变量是不是线程安全的,取决于它是否被多个线程访问。要使变量可以被安全访问,必须经过同步机制来对变量进行修饰。
若是不采用同步机制的话,那么就要避免多线程对共享变量的访问,主要有下面两种方式
-
不要在多线程之间共享变量
-
将共享变量置为不可变的
咱们说了这么屡次线程安全性,那么什么是线程安全性呢?
什么是线程安全性
根据上面的探讨,咱们能够得出一个简单的定义:「当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的」。
单线程就是一个线程数量为 1 的多线程,单线程必定是线程安全的。读取某个变量的值不会产生安全性问题,由于无论读取多少次,这个变量的值都不会被修改。
原子性
咱们上面提到了原子性的概念,你能够把原子性操做想象成为一个不可分割
的总体,它的结果只有两种,要么所有执行,要么所有回滚。你能够把原子性认为是 婚姻关系
的一种,男人和女人只会产生两种结果,好好的
和 说散就散
,通常男人的一辈子均可以把他当作是原子性的一种,固然咱们不排除时间管理(线程切换)
的个例,咱们知道线程切换必然会伴随着安全性问题,男人要出去浪也会形成两种结果,这两种结果分别对应安全性的两个结果:线程安全(好好的)和线程不安全(说散就散)。
竞态条件
有了上面的线程切换的功底,那么竞态条件也就好定义了,它指的就是「两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition)」 ,线程切换是致使竞态条件出现的诱导因素,咱们经过一个示例来讲明,来看一段代码
public class RaceCondition { private Signleton single = null; public Signleton newSingleton(){ if(single == null){ single = new Signleton(); } return single; } }
在上面的代码中,涉及到一个竞态条件,那就是判断 single
的时候,若是 single 判断为空,此时发生了线程切换,另一个线程执行,判断 single 的时候,也是空,执行 new 操做,而后线程切换回以前的线程,再执行 new 操做,那么内存中就会有两个 Singleton 对象。
加锁机制
在 Java 中,有不少种方式来对共享和可变的资源进行加锁和保护。Java 提供一种内置的机制对资源进行保护:synchronized
关键字,它有三种保护机制
-
对方法进行加锁,确保多个线程中只有一个线程执行方法;
-
对某个对象实例(在咱们上面的探讨中,变量可使用对象来替换)进行加锁,确保多个线程中只有一个线程对对象实例进行访问;
-
对类对象进行加锁,确保多个线程只有一个线程可以访问类中的资源。
synchronized 关键字对资源进行保护的代码块俗称 同步代码块(Synchronized Block)
,例如
synchronized(lock){ // 线程安全的代码 }
每一个 Java 对象均可以用作一个实现同步的锁,这些锁被称为 内置锁(Instrinsic Lock)
或者 监视器锁(Monitor Lock)
。线程在进入同步代码以前会自动得到锁,而且在退出同步代码时自动释放锁,而不管是经过正常执行路径退出仍是经过异常路径退出,得到内置锁的惟一途径就是进入这个由锁保护的同步代码块或方法。
synchronized 的另外一种隐含的语义就是 互斥
,互斥意味着独占
,最多只有一个线程持有锁,当线程 A 尝试得到一个由线程 B 持有的锁时,线程 A 必须等待或者阻塞,直到线程 B 释放这个锁,若是线程 B 不释放锁的话,那么线程 A 将会一直等待下去。
线程 A 得到线程 B 持有的锁时,线程 A 必须等待或者阻塞,可是获取锁的线程 B 能够重入,重入的意思能够用一段代码表示
public class Retreent { public synchronized void doSomething(){ doSomethingElse(); System.out.println("doSomething......"); } public synchronized void doSomethingElse(){ System.out.println("doSomethingElse......"); }
获取 doSomething() 方法锁的线程能够执行 doSomethingElse() 方法,执行完毕后能够从新执行 doSomething() 方法中的内容。锁重入也支持子类和父类之间的重入,具体的咱们后面会进行介绍。
volatile
是一种轻量级的 synchronized
,也就是一种轻量级的加锁方式,volatile 经过保证共享变量的可见性来从侧面对对象进行加锁。可见性的意思就是当一个线程修改一个共享变量时,另一个线程可以 看见
这个修改的值。volatile 的执行成本要比 synchronized
低不少,由于 volatile 不会引发线程的上下文切换。
关于 volatile 的具体实现,咱们后面会说。
咱们还可使用原子类
来保证线程安全,原子类其实就是 rt.jar
下面以 atomic
开头的类
除此以外,咱们还可使用 java.util.concurrent
工具包下的线程安全的集合类来确保线程安全,具体的实现类和其原理咱们后面会说。
本文分享自微信公众号 - Java建设者(javajianshe)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。