在上一章我们介绍了线程基础相关的内容,知道了什么是线程、线程存在的状态以及Java如何创建线程等内容,线程是程序执行的基本单元,任何程序都离不开线程,本章我们就来讲讲多线程的基础内容,什么是多线程呢?从字面上来说就是多个线程嘛,我们在学校写的小项目甚至在一些中小型的公司项目中通常都不会涉及到多线程相关的内容,主要是因为这种项目的用户群体不大,像淘宝这种超大型项目来说解决基础的业务是最简单的一件事情,复杂的是要做到如何支撑住在同一时刻处理和响应N次客户的操作请求,针对客户群体的不断扩大而产生的这种难题,我们可以看到业界采取的措施有在硬件上面升级、分布式系统的构建、分发请求到不同的服务器上的负载均衡策略等等,这些内容博主后续会持续更新(这里有点扯远了),在本套文章体系里面我主要是讲Java多线程以及并发相关的内容。其实对分布式系统来说,虽然是根据不同业务模块来划分一个个子系统从而来降低服务器的压力,但是像大型项目中的单个子系统还是会面临着高并发请求,因为客户群体太大了,如果用单个线程的处理是行不通的,我举个直白的例子:比如你开个足疗店,某个时间点突然来了五个人到你的店里需要你的服务,而你的店里只有你一个人,这个时候你只能先给其中的一位进行服务,服务完才能继续服务下一个,那其他四个就只能干巴巴的等着,或许有些人不耐烦会走掉的。这个时候多线程处理程序就尤为的重要了,比如你请多几个店员就能同时服务多个顾客啦。
多线程应用程序无非就是创建多个线程再为每个线程分配任务然后然让cpu调度执行,创建多线程并让线程去执行任务这一步并不难,实现多线程并发程序难是难在如何做到线程安全、多个线程间同时工作的时候它们共享的公共数据在它们之间的一致性问题如何保证,我们这里说的同时工作是有前提条件的,现在的计算器普遍都是多核的,所以可以做到每个核能在同一时刻执行不同的指令,如果是单核的话,其实在同一时刻只能让一个线程执行,计算机是通过调度策略的在多个线程间切换着执行的,我们先不多说,来看一个例子体会一下什么是线程安全问题:
public class CountTest {
public static int count = 0; // 计数
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool(); // 创建一个线程池
for (int i = 0; i < 100; i++){ // 循环一百次,每一次给线程池添加一个任务,每个线程都去对count+1操作
executorService.execute(new Runnable() {
@Override
public void run() {
count++;
}
});
}
executorService.shutdown();
System.out.printf("总计:" + count);
}
}
上面例子就是循环一百次,每次向线程池任务队列里面添加给count加一的任务,也就是线程池每次有空闲下来的线程要去做的任务就是对count+1,按道理来每个线程对count加一也就是总共加了一百次之后值应该为100,但是你运行多次都会发现count的值都不是一百,在一百以内而且还不固定,为啥?这就是线程安全问题,count是多个线程所共享的数据,我们先不讲解如何去解决这种问题,先得了解最底层的原因。我们是要不断的去学习,但是在学习之前我觉得更应该要知道我们为什么要学习即将要学的东西,这样在学习的过程中才更有目的和方向感,我每篇文章开头的简介就是想起到这么个效果,不多说我们继续讲述内容。
在讲述这个问题的原因之前,我们先来看下Java内存模型(JMM),注意这里是JMM和JVM内存模型的区别,其实这个地方有时候一些面试官也没有清楚的区分开来,往往面试官可能会让你讲讲Java内存模型,而你却说了JVM内存模型相关内容,这个时候给你面试的人也会表示你回答的是符合他问的,但是其实这是两块内容。
JVM内存模型是在java运行时对物理内存划分,Java内存模型我这里贴一个公众的解释:Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。我们通过图来分析Java内存模型抽象结构图:
右图是结合JVM内存模型所展示的,关于五个区域的内容在JVM内存模型已经详细讲啦,在这里大家要知道的就是右图的Thread Stack和左图线程的本地内存对应、右图Heap与左图主内存对应,这里说的对应是指里面内存里面的相关内容,比如说Heap存放的就是所有线程共享的数据,Thread Stack是线程私有的数据。
左边图这里有两个概念就是:主内存和本地内存(也就是线程的工作内存),主内存里面存放的是所有线程的共享数据,而线程的各自的工作内存是每个线程私有的,有每个线程私有的数据、也有拷贝于主内存共享数据的副本,每个线程对数据的所有操作(读写等)都是在自己的工作内存当中,不能直接对主内存的共享变量进行读写操作,这也就说明线程间如果要进行通信、相互协调工作的话,那他们对它们进行通信的时候通信的媒介(这里就是共享数据嘛)肯定就是要经过主内存的,最基本的通信过程肯定是:线程A从主内存中把共享数据D读入到本地内存A后对数据D进行操作,然后再将数据D同步到主内存当中,然后线程B再从主内存读入数据D到本地内存B中进行操作,这就完成了他们之间的通信流程,现在我们来思考一下这应该是最理想的过程吧?因为线程B并不知道线程A已经对数据D进行操作并且同步到主内存了,也就是说线程B并不知道此时主内存的数据D是最新的。(其实我们现在的多线程同步相关的机制就是为了达到这个理想的过程)
通过对上面两个图的描述现在我们就来拿我们上面的代码例子来讲解:
count属于静态共享数据会在主内存当中,此时程序发起多个线程同时执行对count+1的任务,假设现在就同时有两个线程A和B要执行任务,刚开始他们在自己的工作内存当中是没有count这个共享数据的副本的,所以他们会先去主内存中拷贝count的副本到自己的工作内存,此时在各种的副本当中count的值都是0,然后他们都各自将本地的count+1,完了之后两个线程同步给到主内存中的值都是1,按道理此时count的值在主内存应该是2,接着可能又有多个线程需要对count+1的任务,那一样会出现上面这种情况,因为多个线程在目前看来他们自己都是“我行我素”,不会知道也不会关心与它们在共同工作的其它线程,才导致为什么结果不为100。
相信到这里大家结合Java内存模型应该知道了在多线程并发的处理情况下线程安全问题的出现原因,那我们后续的关于线程安全问题的解决思路也是围绕这个原因进行的。
我们通过上面的分析可以清楚的知道Java内存模型的结构以及在多线程并发运行的情况下会出现的线程安全问题的原因,其实我们已经明白如果要解决线程安全问题,就需要让多个线程在相互工作的时候能够“感知”到有其它线程的存在并且“知道”其它线程关键的工作情况,这就是多线程同步,就拿上面的例子来说线程A和线程B对共享数据count操作的时候,先得让A和B有次序的进行,我们可以根据上面的例子和问题分析来思考有什么思路可以解决上面的问题?
解决思路:大家要明白即使是多个线程同时在运行,但是总有一个线程是最先去拷贝共享数据count到其工作内存的,这里假设A是第一个,那这个时候解决多线程安全问题的一个思路就是可以在A拷贝count的时候,先将count的数据“锁起来”,这样B过来的时候就无法对count进行操作了,因为被锁住了,那B就知道此时有其他线程在用这个共享数据,等A执行完任务将count的值更新到主内存当中的时候就把锁给打开等B再来试着拿的时候就可以拿到并对count操作啦,这就是一种同步思路。
在Java内存模型有八种同步操作和对应的同步规则,我们先来看下一张图:
其实Read和Load、Store和Write没有严格的界限,前者两步就是将主内存的共享数据拷贝副本到工作内存,后者就是将工作内存的数据更新到主内存当中,这就是Java内存模型有八种同步操作,其实同步规则就是为了解决安全问题来规定这八种操作的,下面我们来看下都要哪些规则:
这些规则都是比较详细的概述出要做到线程安全的一些规则,大家不要强行去记背,首先需要知道最基本的八种操作,然后再结合对线程安全问题出现的底层原因来理解和感受,这样对自然就能深刻理解这些规则了,后续避免多线程安全问题的机制也是围绕这些来的。
线程安全性可以理解为在多个线程访问某个共享数据的时候,不管CPU如何调度执行这些线程,而且对这些线程没有进行同步等相关的操作也能保持正确的行为,那这个数据就是线程安全的,这里的共享数据大家不要局限于基本的变量数据,在java中还可以是共享的类等数据。
线程安全性主要体现在三个方面:
原子性:提供了数据的互斥访问,同一时刻只能有一个线程来对它进行操作
可见性:一个线程对主内存的数据修改可以及时的被其他线程观察到
有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序
在这里只是简单的介绍什么是安全性以及主题体现的三方面,接下来的文章就会结合Java自带的线程安全工具类来讲述线程安全性,分别从原子性、可见性和有序性进行分类展开介绍。