为什么需要多线程?
我们大家都知道,cpu,内存,磁盘三者在处理速度上是有着较大的差异,所以为了合理的利用cpu的资源,平衡三者的速度差异。所以计算机操作系统做了以下三件事
- CPU增加了缓存-->均衡了cup与内存的速度差异-->导致可见性问题
- 操作系统增加了进程,线程,以分时复用CPU-->均衡了cpu和磁盘速度差异-->导致原子性问题
- 编译程序优化指令执行次序-->使得缓存得以充分利用-->导致有序性问题
可见性
可见性:一个线程对共享变量的修改,能够被其它线程立即看到
代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假设执行线程1的代码是cpu1,线程2执行的代码是cpu2,由上面的分析可知,由于cpu增加了缓存,cpu1执行完i=10这条语句后,会将i的初始值加载到cpu的高速缓存中,然后赋值为10,即在cpu1中的高速缓存中的值变为10,但是并没有立即写入主存
此时cpu2会执行j=i的命令,cpu2会去主存中读取i的值,但是此时主存当中的i的值为0,那么就会导致cpu2所拿到的值为0而非10
分析:线程1对变量i修改后,线程2并没有立即看到线程1修改的值,没有保证可见性,导致可见性问题
影响:看如下实例代码
public class ThreadUnsafeExample {
private int i = 0;
public void add() {
i++;
}
public int get() {
return i;
}
/**
* 使用1000个线程对变量i进行++操作
*
* @param args
* @throws InterruptedException
*/
public static void main(String[] args) throws InterruptedException {
// 线程数量
final int threadSize = 1000;
// 新建操作i的对象
ThreadUnsafeExample example = new ThreadUnsafeExample();
// 用线程池技术创建线程
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
// 循环1000次,创建1000个线程
for (int i = 0; i < threadSize; i++) {
// 每个线程触发对i++操作
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
// 输出i的值
System.out.println(example.get());
}
}
执行结果:
i的值为 = 979
由结果看出,每次执行程序的结果都会小于1000,且每次都会有不同的值。这就是线程与线程之间的可见性问题,导致有的线程在操作i的值时还为将其写入内存就被其它线程所读取。导致小于1000
原子性
原子性:即一个操作或者多个操作,要么全部执行,要么全部不执行
经典的转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题
有序性
有序性:即程序执行过程中按照代码的先后顺序执行
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
从代码顺序上来看,语句1是在语句2执行之前执行的,那么jvm在执行程序时语句1一定在语句2之前执行吗,答案是否定的,因为可能会发生指令重排序
总结
若不遵循以上3个性质,就会导致并发性问题
java怎么解决这并发性问题呢?
分析了可能引起并发的三个原因,所以java需要保持以上三大特性即可解决并发问题
保证可见性
Java提供了volatile关键字来保证可见性
当一个共享变量被voatile所修饰时,它会保证修改的值会立即更新到内存中去,当有其它线程读取时,获取的是最新的值,即保证了可见性
而普通的共享变量不能保证可见性,因为普通的共享变量在被修改之后,何时被加载到内存当中是不确定的,因此无法保证可见性
另外,synchronized和lock也能够保证可见性,两者可通过保证同一时刻只有一个线程获取锁然后执行同步代码,并且在锁释放之前会将变量的修改刷新到主存当中,因为保证了可见性
保证原子性
在java中,对基本数据变量的读取和赋值是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
分析以下以下那条命令是原子性
请分析以下哪些操作是原子性操作:
x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1; //语句4: 同语句3
答案:上面四条语句只有第一条语句才是原子性操作
也就是说简单的读取,赋值才是原子性操作
如果想实现更大的原子性操作,可以通过synchronized和lock来实现,两者都保证了任意时刻只有一个线程执行该同步代码块,也即不存在原子性问题,从而保证了原子性
保证有序性
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。