1.1 多线程一定快吗?
有人做过这样一个实验:“一段代码,有两个方法对各自的属性进行累加操作,其中一个方法采用多线程”,部分结果如下:
我们可以明显的看到,多线程不一定比单线程快
1.2 上下文切换
单核处理器也支持多线程执行代码:
通过给线程分配时间片来实现这个机制。
时间片一般是几十毫秒,所以CPU需要通过不停地切换线程来执行。当前执行完一个时间片后会切换下一个任务。在切换前会保存当前任务的状态,可以恢复这个任务之前的状态。 任务从保存到再次被加载的过程就是一次上下文切换。
时间片:操作系统分配给每个正在运行的进程(线程)微观上的一段CPU时间
测试上下文切换:
当我们一直运行多线程程序时,发现CS飙升到1000以上。
1.3 Java内存模型
在深入学习synchronized关键字之前,有必要先了解一下Java的内存模型
Java Memory Model(JMM):
Java线程之间的通信由JMM来控制,其决定一个线程对共享变量的写入,何时对另外一个线程可见。
为了提高效率,线程之间的共享变量是存储在主存当中,每一个线程都有一个属于自己的本地内存。
如果线程A与线程B之间要通信,需要经历下面两步:
1 线程A把本地内存中更新过的共享变量,刷新到主存中。
2 线程B到主存中重新读取更新后的共享变量。
1.4 主存与工作内存间的数据交互过程
lock :作用于主存的变量,把一个变量标识为线程独占状态
unlock :作用于主存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read :作用于主存变量,它把一个变量的值从主存传输到线程的工作内存中,以便随后的load动作使用
load :作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
use :作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用的变量的值,就会使用到这个指令
assign :作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
store :作用于主存中的变量,它把一个从工作内存中一个变量的值传送到主存中,以便后续的write使用
write :作用于主存中的变量,它把store操作从工作内存中得到的变量的值放入主存的变量中
JMM对这八种指令制定了如下规则:
不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
不允许一个线程将没有assign的数据从工作内存同步回主内存
一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
对一个变量进行unlock操作之前,必须把此变量同步回主内存
2.1 多线程带来的可见性问题
可见性:一个线程对主存的修改可以及时被其他线程观察到。
上图,线程一把flag属性读取到线程私有的本地内存中,值为false;线程二把flag属性修改为true,并且刷新到主内存当中,但是线程一不知道flag被修改了。
解决方案:如果有加同步的机制,则会有lock、unlock操作,lock操作会使本地内存中的属性失效,从而去主内存中重新读取数据。
2.2 多线程带来的原子性问题
原子性:同一个时刻只能有一个线程来对它进行操作。
如果使用五个线程同时对变量 i 进行1000次 ++ 操作,最后的结果并不一定等于5000。
反编译结果可以看出:
index ++ 一共涉及到4条指令;
假设线程一执行到步骤三时CPU切换线程。线程二执行步骤一,这时index的值还是等于0,因为线程一并没有执行步骤四就被切换上下文了。 等线程二执行完成,又切回到线程一,线程一会接着执行步骤四,并不会重新获取index的值,导致计算结果不正确。
当加上了synchronized同步机制之后, 会插入monitorenter、monitorexit两条指令。
若线程一执行到步骤三,被切换到线程二,线程二执行monitorenter指令时会发现,这个对象已经被其他线程占用了,所以只能等待。
当又切回到线程一时,线程一操作完整个步骤执行monitorexit来释放锁。此时线程二才可以获得锁。 这样就能保证原子性。
2.3 多线程带来的有序性问题
有序性:Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序与编写的顺序不同。
一个典型的例子就是JVM中的类加载:
类从加载到JVM到卸载一共会经历五个阶段:加载、连接、初始化、使用、卸载。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即验证、准备、解析阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。
解决方案:加锁。
2.4 多线程带来的活跃性问题
活跃性问题关注的是:某件事情是否会发生。
典型的就是死锁问题:如果一组线程中的每个线程都在等待一个事件的发生,而这个事件只能由该组中正在等待的线程触发,这种情况会导致死锁。
2.4.1 死锁的4个必要条件
造成死锁的原因有四个,破坏其中一个即可破坏死锁
互斥条件:在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程释放。
请求和保持条件:进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持占有。
不剥夺条件:进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
循环等待:在发生死锁时,必然存在一个进程对应的环形链。
2.5 性能问题
文章开头就提到过,在多线程中有一个非常重要的性能因素就是上下文切换。线程间的切换会涉及到一下几个步骤:
将CPU从一个线程切换到另一线程涉及挂起当前线程,保存其状态,例如寄存器,然后恢复到要切换的线程的状态,加载新的程序计数器。
2.5.1 引起线程切换的几种方式
当前正在执行的任务完成,系统的CPU正常调度下一个需要运行的线程
当前正在执行的任务遇到I/O等阻塞操作,线程调度器挂起此任务,继续调度下一个任务。
多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务。
用户的代码挂起当前任务,比如线程执行sleep方法,让出CPU。
使用硬件中断的方式引起