1.基础概念
CPU核心数与进程数关系
笔者目前CPU核心数为4核,一般而言,CPU核心数与进程数比为:1:1,换句话说,四核CPU的计算机同一时刻可以运行4个线程。自从因特尔公司引入了超线程技术以后,CPU核心与超线程之比为1:2。
CPU时间片轮转机制
CPU时间轮转机制也叫上下文切换。即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为分配的时间比较短,所以CPU不停的切换线程执行,让我们感觉到是多个线程同时执行的,时间片一般是几十毫秒。如果线程在时间片周期内执行完了,会切换到另一个线程;如果没有执行完,则在下次切换到这个线程时继续执行。这样的切换是很浪费时间的,假如某个线程时间片分20ms,而切换线程花了5ms,那么效率就降低了20%,所以在程序开发之中,尽量减少线程之间的切换。
什么进程和线程
进程:程序运行资源分配最小单位,一个进程内部有多个线程,会共享进程里的资源。
线程:CPU调度的最小单位,线程不拥有系统资源,必须要依附于进程存在。
并行与并发
并行:同一时刻,可以同时处理事情的能力
并发:单位时间内可以处理时间的能力
举个栗子,笔者CPU是四核的,那么某一时刻,计算机有四个线程在处理事情,属于并行;在某段时间其中一个线程处理的事情能力,属于并发。在假设,笔者去食堂打饭,有多少个窗口属于并行,某段时间单个窗口给你打饭的阿姨给你打饭的效率就属于并发。
高并发编程的好处,意义以及注意事项
举个栗子,笔者CPU四核,如果只有一个线程在运行,另外三个就白白浪费了。所以合理分配CPU资源能够高效的提升计算机的运行性能。 在使用并发编程开发同时也会遇到以下问题:
1.线程能共享进程里的资源,引发资源冲突
2.线程上下文切换,耗时
3.死锁,线程数太多会导致系统崩溃
2.原子操作CAS(compare and swap )
2.1 术语定义
Atomic本意是"不能被进一步分割的最小粒子",这让笔者联想到mysql数据库事物的原子特性,事务执行的数据操作要么全部提交,要么不操作。
缓存行(cache line): 缓存最小的操作单位
比较并交换(Compare and Swap):CAS操作需要比较两个值,一个是内存地址里的值,一个是期望值,如果两个值相同,则将内存地址里的值改成要修改的值。如果这两个值不相同,无限自旋下去。
CPU流水线(Cache pipeline):CPU流水线的工作方式像工业生产的专配流水线,在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步再有这些电路单元分别执行。
内存顺序冲突(Memory order violation):内存顺序冲突一般是由假共享引起的,假共享是指多个CPU指令同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。
2.2 CAS原理
三个运算符:一个内存地址V,期望值A,要修改的值B 获取内存地址V上的值与A值进行比较,如果相同,则将内存地址V上的值改成B;如果不相同,则无限循环下去,知道相同为止,这种操作又称自旋。
2.3 处理器如何实现原子操作
2.3.1 使用总线锁保证原子性
如果多个处理器同时对一个共享变量进行写操作时,那么共享变量可能被多个处理器同时进行操作,这样都改写操作就不是原子的,操作完之后共享变量的值可能核期望的值不一样。举个栗子,假如i=1,现在对i进行了两次++操作,我们期望值是3,但是有可能读出来的是2。为什么呢?多核处理器下,CPU1读到值是1,CPU2同时读到的值也是1,都进行了++操作,最后i有可能就是2了。 处理器是使用总线锁来解决这个问题的,所谓总线锁就是使用处理器提供的一个LOCk#信号,当一个处理器上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存
2.3.2 使用缓存所保证原子性
上节说到总线锁来保证处理器的原子性操作,总线锁会把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁的开销比较大,目前处理器在某些场合下都是使用缓存锁来代替总线锁来进行优化。
所谓缓存锁就是指一个内存地址的数据被缓存到处理器缓存行里之后对它进行修改操作时,这一数据被缓存到其他处理器缓存行无效。换句话说,一旦一个处理器的缓存行数据被修改,其他处理器无法对这个变量进行修改了。
#2.4 JAVA实现原子操作
(1)使用循环CAS实现原子操作 三个变量值; V(内存地址),A(期望值),B(要修改的值) 首先获取内存地址V的上值,跟期望值A进行比较,如果相同,就把内存地址V上的值改成B;如果不相等,就无限循环下去,直到相等。
(2)CAS实现原子操作的三大问题
ABA问题
如果一个值原本是A,变成了B,又变成了A,表面上看是没有变化,实际上确实变化了。ABA问题的解决思路是使用版本号,在变量前面追加版本号,1A->2B->3A 。从JDK1.5开始,Automic相关工具类下compareAndSet就是检查当前应用是否与预期 相同,如果相同在给定更新值。
循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供pause指令,那么效率一定会提升。
只能保证一个共享变量的原子操作
当需要多个共享变量进行操作时,循环CAS就无法保证操作的原子性,这个时候可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个,比如有两个共享变量,a=1,b=x,合并一下ab=1x。在JDK1.5以后提供了一个工具类AutomicRefrence来保证多个引用变量之间的原子性。
3.volatile的实现原理与应用
3.1 volatile的定义
在多线程开发之中,synchronized和volatile都扮演着重要的角色,都保证了数据的“可见性”。volatile修饰的变量在被某一个线程修改时,其他线程能够读到这个修改的值。分析volatile实现原理之前,来了解下CPU术语的定义。
内存屏障(memory barriers) 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行(cache line) CPU高速缓存中可以分配的最小单位,处理器填写缓存时会加载整个缓存行,现代需要执行几百次CPU指令
原子操作(automic operations) 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的内存(L1,L2,L3的或所有)
缓存命中(cache hit) 如果进行高速缓冲行填充操作的内存位置任然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取。
写命中(write hit) 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否存在缓存命中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作数被称为写命中
写缺失(write misses the cache)一个有效的缓存行被写道不存在的内存区域
3.2 volatile实现原理
volatile是如何来保证可见性的呢?在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看volatile进行写操作时,CPU会做什么事情。JAVA 代码如下:
instace = new Singleton();
转变成会汇编语言如下:
0x01a3de24:lock add1 $0*0.(%esp);
通过查询IA-32架构软件开发者手册可知,Lock前缀的指令会在多核处理器下做两件事情:
-
将将当前缓存行的数据协会到系统内存
-
这个写回内存的操作会是其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到缓存(L1,L2或其他)后再进行操作。如果对申明了volatile的变量进行写操作,JVM就会像处理器发送一Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。但是,就算写回到内存,其他处理器缓存到值还是旧的,读取其他缓存行的数据进行操作依然会出现问题。所以在多核处理器下,为了保证处理器缓存是一致的,就会实现缓存一致性,就是MESI协议(MESI指的是缓存行中的四种状态:可修改的,独有的,可共享的,无效的),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态。
下面详细来分析下volatile的两条实现原则:
1)Lock前缀指令会将缓存行里的数据全部回写到内存
2)一个处理器的缓存行数据回写内存会使其他处理器的缓存行里缓存了该数据的缓存行的缓存失效。
4 synchronized的实现原理和应用
4.1 synchronized简单介绍
在多线程并发编程中synchronized一直是元老级角色,很多人把它称为重量级锁,随着JDK1.6对synchronized进行了各种各样的优化之后,有些情况下它并没有那么重了。
对于普通方法,锁是当前实例对象 对于静态方法,锁是当前实体类 对同步方法块,锁是synchronized括号里配置的对象
4.2 java对象头
synchronized用的所是存在JAVA对象头里的。如果对象是数组类型,则虚拟机用三个字宽(word)存储对象,如果对象是非数组类型,则用两个字宽存储对象头。在32位虚拟机中,1字宽等于4字节。
JAVA对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。
4.3 锁的升级与对比
在JAVA SE1.6中,锁一共有4中状态,级别依次是:无所状态,偏向锁状态,轻量级锁状态和重量级锁状态。
4.3.1 偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来进行加锁和解锁,只需要简单的测试一下Mark word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark word中偏向锁的标识是否设置成1(表示当前是偏向锁);如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。 1)偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。它会先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活着,如果线程不处于活动状态,则将对象头设置成无所状态;如果线程仍然活着,当前线程撤销偏向锁,升级为轻量级锁。
2)关闭偏向锁
可以通过JVM参数关闭偏向锁: -XX:-UseBisasedLocking=false,那么程序默认会进入轻量级锁状态
4.3.2 轻量级锁
(1)轻量级锁加锁 线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储所记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为锁记录的指针。如果成功,当前线程获得锁,若果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级解锁 将mark word替换回对象头,如果成功,则表示没有竞争发生;如果失败,则表示当前锁存在竞争,锁就会膨胀成重量级锁。
4.3.3 重量级锁
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。