最近Java的网课上到“concurrency”的部分,花了三天仔细地看完了整个章节,除了最后几节涉及到javaFX的内容,其他大部分代码都跟着敲了,跑了,challenge也基本做了。之前对多线程这一块很不熟悉,现在这样一趟下来,感觉收获很多。也可以说是比较系统地学习了一下这一部分的内容。因此觉得有必要写篇文章概括总结一下,也方便今后翻阅。
在说多线程之前,先要简单说一下进程和线程的概念。首先线程是包涵在进程里的,一个java程序就对应了一个进程。一旦启动,计算机就要为它分配内存等资源。而进程里可以有多个线程(如果一个进程没有单独开辟线程,则视为是单线程)。计算机是不会专门为线程分配资源的(除了cpu时间片)。同一个进程下的各个线程会共享同一块内存区域,也会有各自的一块工作区,线程工作时,就从共享内存中读取变量等数据到自己的工作区中进行操作,然后写回共享内存。线程之间的工作区是互不可见的。那么多线程的好处是什么呢?最大的好处就在于提高效率。举个例子,我们准备晚餐的时候,既需要炒菜,也需要煮饭。我们一般都是先把电饭煲打开开始煮饭,10分钟后它自动就好了,那这中间10分钟我们一直干等着饭煮好再去炒菜吗?肯定不会,我们肯定是电饭煲按钮按下后就开始炒菜了。然后10分钟后菜好了,饭也好了,就可以吃了。而对应到计算机中,也许就是它需要一边去数据库读取数据,一边在前端页面响应你的请求等,这就是多线程的优势了。知道了多线程的优势,那么怎么使用多线程呢,或者说,怎么让我的程序拥有多线程呢?
多线程是需要手动创建的,换言之,我们平时leetcode或者自己写的小程序里往往都是单线程的。创建线程的方法有3,4种,但是主流的是两种,一种是创建一个类继承Thread父类,一种是创建一个类实现runnable接口。然后override其中的run()方法,在这里面指定这个线程需要执行的命令。可以是打印一条语句,也可以是执行某个函数(不过似乎是不能传入参数)。我看了一下网上,说是第二种用的比较多,因为java是单继承,局限比较大。那么线程创建出来之后,它就处于就绪的状态了。一旦我们调用start()方法,就会把它加入到队列里等待执行。然后操作系统会综合考虑各种因素,决定按什么顺序执行它们。值得一提的是,我们可以给thread设置优先级,但是这个优先级更像是一种对操作系统的建议,并不能真正决定它的被执行顺序,而且从实际效果看来,这个建议的效果相当有限。还有就是,操作系统执行线程的顺序存在很大的随机性,所以在写多线程的程序时,同样的代码运行三次,看到三个不同的结果,也是不足为奇的。
多线程的好处我们说了,接下来说说它的隐患。它的隐患就是thread interference。正如前面所提到的,线程之间是不可见的,那么如果线程以某种不恰当的顺序执行的话,则可能出现错误的结果甚至导致程序崩溃。举两个简单的例子:
第一个例子: count ++; 这个简单的语句其实并不是原子性的,当它被线程执行时,其实包含了三步操作:第一步,从共享内存中读取count的值到线程的工作内存中。第二步,在工作内存中对count的值进行加一。第三步,将值写回共享内存中。那么如果两个线程同时在执行这个语句的话,会发生什么问题呢?假设现在有线程A,线程B,Candidate类里有个属性count,count值为0。
1. 线程A从共享内存中读取了count = 0,在自己的工作区中对它进行加一,count值变成了1,然后线程A被pending了
2.线程B被执行,线程B也从共享内存中过读取了count = 0,也在自己的工作区中对它进行了加一,count值变成了1,然后被pending了
3. 此时线程A恢复运行,并将它工作区里count为1的值写入共享内存,此时内存中count的值为1
4.然后线程B恢复运行,并将它工作区里count为1的值写入共享内存,此时内存中count的值仍为1
所以这就出问题了,count的值本该为2,但是最后却变成了1。这就是值错误,接下来还有更严重的例子。假设有两个函数:function A 和 function B, 对同一个list 进行操作。function A 里判断if (! list.isEmpty()) int x = list.get(0); function B 里if (! list.isEmpty()) int x = list.remove(0);假设有线程A和线程B,list里有一个元素,那么可能出现如下情况:
1. 线程A执行判断语句,发现list不为空,然后此时线程A被pending,线程B被执行。
2. 线程B执行判断语句,发现list不为空,于是remove掉了list中的唯一元素。
3.线程A被恢复执行,试图从空list中取元素,报错。
通过这两个例子,大家应该能隐隐到感觉到问题的根源所在:线程执行期间随时可能被打断,这让我们的判断语句失去意义,即使你现在判断list是非空的,到你去取数据的时候,它可能已经空了。堪称计算机版的“你永远不知道明天和意外哪一个先到来。” 或者是读了一个已经过时的值,之后所有努力都不过是在错误的道路上渐行渐远。
曾经有这么一个段子:一个年轻人去面试,面试官说:你简历上说你心算很快?那我考考你。42 * 37等于多少,年轻人脱口而出5211。面试官一按计算器,不禁大跌眼镜,说你这差的也太远了吧?年轻人回答到:我说我心算快,没说我心算准。
所以说程序的效率固然重要,但是如果完全抛弃了安全性,可靠性的话,那就是舍本逐末了。那么怎样避免线程间的干扰,提高结果的可靠性呢?方法有很多,这里主要介绍三种,分别可以用三个关键词代表:synchronized, reentrantlock, volatile。
先说synchronized,这是一个java 关键字,可以用来修饰一个方法,也可以用来修饰方法中的某一块代码。本质上说,synchronized的实现机制就是锁,是一种非公平锁,在此不做展开。现阶段你所需要知道的就是synchronized的作用就是它可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。举个例子(假设在那个例子中线程A和线程B都是对同一个object进行操作,假设object是lion),如果我们对count ++ 所在的函数使用了synchronized,那么就能避免线程的干涉,因为当线程A开始执行时,会获得lion的锁,这是个互斥锁,线程B就不能再随意去对lion进行操作了,它必须等待,直到线程A释放了锁。需要注意的是,synchronized是会降低程序效率的,因为底层实现过程中会有很多上下文切换的开销等,所以要视情况使用,如果一个函数很大,其中只有一部分代码需要synchronized的话,我们也可以直接synchronized那部分代码块,而不是整个函数。或者有些时候直接用更轻量级的volatile代替。synchoronized方便好用,但使用时需要注意随之而来的两大隐患:deallock或starvation。这里简单说下这两种情况:
场景一: 假设有一个Person类,类里有两个方法:sayHello(), sayHelloBack():
public synchronized void sayHello(Person person){
System.out.format("%s: %s" +" has said hello to me!%n", this.name, person.getName());
person.sayHelloBack(this);
}
public synchronized void sayHelloBack(Person person){
System.out.format("%s: %s" +" has said hello back to me!%n", this.name, person.getName());
}
两个function都被synchronized修饰。此时,实例化两个Person,neo和jane,新建两个线程,分别执行neo.sayHello(Jane),jane.sayHello(neo)。那么就会出现这种情况:
1. 线程A启动,得到了neo的锁,然后输出 “jane has said hello to me!”,然后suspend。
2. 线程B启动,得到了jane的锁,然后输出 “neo has said hello to me!”,然后suspend。
3. 线程A恢复执行,试图调用jane的sayHelloBack(),但是jane的锁被线程B占据着,所以它只能等线程B释放锁。
4.线程B恢复执行,试图调用neo的sayHelloBack(),但是neo的锁被线程A占据着,所以它只能等线程A释放锁。这么一来,deadlock就出现了。所以在使用锁时,需要注意锁的使用顺序。
其实这里还有一个问题我是比较疑惑的。这个例子我是跑过的,确实deadlock了,那就是说synchronized会锁住整个对象吗?前面虽然调用的是sayHello的方法,但是别的线程不但不能调用它的sayHello,连sayHelloBack也调用不了了。这么看来感觉效率影响很大,而且如果它会影响到所有funciton的话,synchronized function还有什么意义呢?不知道是不是在这一块理解有什么偏差。这里先打个问号吧。
deadlock是多线程系统中的重要话题,但是没有deadlock不代表系统就是完好的,还需要看有没有starvation。这就要提到公平锁和不公平锁,这里的公平是什么意思呢?使用公平锁时,线程需要去队列里排队,永远都是队列中第一个线程获得锁。大家排好队,一个个来,这样能够让每个线程都能得到锁。而另一种就像是丛林法则了,有一个抢占锁的过程,只有抢不到了,才回到队列。这样的机制下,就可能出现有的线程尽管来的早,却一直抢不到锁,也就会出现starvation的现象。虽然非公平锁听上去很野蛮,但是却效率更高,因为cpu不必唤醒所有进程,减小了开销。包括我们上面提到的synchronized和接下来要说的reentrantlock都是非公平锁(默认情况下)。宽泛地说,公平锁和非公平锁各有优缺点,需要视情况而定。比如说如果线程占用时间远长于线程等待时间,那么用非公平锁就优于用公平锁。这里就不做过多的展开了。
接下来说说reentrantlock,在用synchronized的时候,有一个局限性,就是只能对某个或function中的一部分进行加锁,无法跨function。而reentrantlock就解决了这一问题,虽然synchronized和reentrantlock都是锁,但是在用retrantlock的时候我们需要手动的加锁,解锁。这也让它的使用变得更加灵活,不局限于某个function。reentrantlock的用法很简单,new一个新的reentrantlock对象,然后在需要加锁的代码前使用lock()方法(经常会使用trylock()来提高效率),再在之后unlock()。reentrantlock方便,好用,也有多种选择(可通过重载函数的途径创建不同类型的锁)。
最后说说volatile,volatile是一个用来修饰变量的Java关键字,它主要是为了避免脏读现象。什么是脏读呢?就是假设有两个线程对同一个变量进行操作,线程A刚在自己的工作区对变量值进行了修改,但还没来及写回共享内存,线程B就从共享内存中读取了一个过时的值。这就是脏读。那么volatile是怎么解决这个问题的呢?使用volatile修饰的变量会强制将修改的值立即写入共享内存,而共享内存中值的更新会使缓存中的值失效,这样别的线程就不会读到过时的值了。
那么这几天来学的多线程的基本知识点到这也就差不多了。它里面涉及锁是一个很大的话题,有很多不同类型的锁,有不同的优劣和适用场景。但是我现在也没怎么去深入了解,这个话题就留待以后有兴趣和机会的时候再深入吧。现在就先说这么多。