线程与锁模型
线程与锁模型是比较原始的一种处理并发的方式,主要是对底层硬件的运行过程形式化,这是它的优点也是缺点。
线程与锁模型非常直接,几乎所有的编程语言都提供了支持,但是如果不了解该模型,那么程序会很容易出错,而且难以维护。
为什么需要锁
我们先来看一段多线程的代码:
public class Counting {
public static void main( String[] args) throws InterruptedException {
class Counter {
private int count = 0;
public void increment() { ++count; }
public int getCount() { return count;}
}
final Counter counter = new Counter();
class CountingThread extends Thread {
public void run() {
for(int x = 0; x < 10000; x++)
counter.increment();
}
}
CountingThread t1 = new CountingThread();
CountingThread t2 = new CountingThread();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
这是创建了两个线程t1与t2,每一个线程都调用了counter.increment(),10000次,看上去特别简单,但是每次运行都会有不同的结果,这是因为在操作counter对象的时候发生了竞态条件。
竞态条件是指代码的行为取决于各操作的时序。
如果不理解我们先看一下java编译器是如何处理的++count
的
//获取值
getfield #2
//将常量i 1 进栈
iconst_1
//加i
iadd
//更新值
putfield#2
问题就出在这里,如果同时调用increment(),两个线程在获取值的时候是同一个值如100,那么放回去的时候虽然操作了两次increment(),但是实际结果是101。
synchronize
java中遇到这种问题可以有一种的解决办法,进行同步(synchronize)访问。
只需要在之前的代码中这么改一下:
······
public synchronized void increment() { ++count; }
······
那么在线程使用increment函数的时候会获得该函数的锁,其他线程将不能访问,直到该线程返回时释放锁。
现在因为增加了同步的代码,执行都会获得正确的结果--20000。
但是synchronize也会带来很多坑,下面一一介绍。
乱序编译
static boolan isReady = false
static int number = 0;
static Thread t1 = new Thread() {
public void run() {
number = 100;
isReady = true;
}
static Thread t2 = new Thread() {
public void run() {
if (isReady)
System.out.println(number);
else
System.out.println("not ready");
}
想想看如果同时运行上面的代码会发生什么事?
结果:
- 打印not ready
- 打印100。
- 打印0。 (为什么?)
为什么 number = 100; isReady = true;语句发生了颠倒?
但是事实上是有可能发生的:
- 编译器的静态优化会打乱。
- JVM的动态优化会打乱。
- 硬件可以通过乱序执行来优化性能。
但实际上还有更糟糕的,有时候一个线程产生的修改可能对于另外一个线程来讲是不可见的。
从常识上来说,无论是编译器,JVM还是硬件都不应该改变代码的原有逻辑,这里我们需要有明确的标准来知道可能会发生什么,那就是Java内存模型。
在Java内存模型中还说明了上面一个问题的答案:
如果读线程与写线程不进行同步,就不能保证可见性。
所以除了increment()之外,也应该对getCount()方法同步,不然可能会得到一个已经失效的值。
死锁
有一个著名的问题--哲学家进餐问题
如图。
哲学家的状态可能是「思考」也可能是「饥饿」,如果是饥饿,他就会将两边的筷子拿起来,并且进餐一段时间。进餐结束后哲学家就会返回筷子。
那么代码可以这么写
synchronized(左边的筷子) {
synchronized(右边的筷子)
}
这样会出现一个问题,如果所有的哲学家在某个时刻,将左边的筷子都拿起来,就都不能拿到右边的筷子了,而且也不能释放左边的筷子,这样程序就会一直卡组,这就是死锁。
如果解决?
我们可以给筷子设置编号,只能先拿小的,然后拿大的。
或者给哲学家拿筷子的顺序进行设置。
虽然可以解决但是依然暴露出synchronized的问题。
方法内部的陷阱
private synchronized void update {
for (Person person: persons)
person.eat();
}
这段逻辑看上去没有问题,方法加上了synchronized,所以多线程使用的时候也会同步访问。
但实际上也是有一个陷阱,在eat方法这个地方。
因为对eat方法不了解,所以可能eat方法中也调用了synchronized函数,这样就是使用了两把锁,就像之前的哲学家进餐问题一样,可能会发生死锁。
解决的办法是将persons拷贝一份:
private void update {
ArrayList<Person> personsCopy;
synchronized(this) {
personCopy = (ArrayList<Person>)persons.clone();
for (Person person: persons)
person.eat();
}
这样调用方法的时候不需要加锁,而且也减少了持有锁的时间。
总结
- 对共享变量需要同步化。
- 读线程和写线程需要同步化。
- 按照约定的全局顺序来获得多把锁。
- 持有锁的时候尽量不要调用外部方法。
- 持有锁的时间应该尽量短。