看下面这个例子,Plane是一个存储飞机状态的类,它有俩个状态变量想想x,y来表示其坐标,Diasphater线程会不停轮流调用Plane的move1方法,和move2方法,Renderer类会根据Plane的状态渲染出这个Plane,我们期望的状态是Plane会在(2,2), (3,3)上不停渲染,而不会出现在(2,3),(3,2)上,但结果是令人失望的。
这个类看似充分进行了同步?我想细心点的大家很容易就能发现其中的问题。
public class Plane {
private int x = 1;
private int y = 1;
public synchronized int getX() {
return x;
}
public synchronized int getY() {
return y;
}
public void renderer() {
System.out.println("飞机的X坐标"+ getX() + "飞机的Y坐标" + getY());
}
public synchronized void move1 () {
x = 2;
y = 2;
}
public synchronized void move2 () {
x = 3;
y = 3;
}
public static void main(String[] args) {
Plane p = new Plane();
new Dispatcher(p).start();
new Renderer(p).start();
}
static class Dispatcher extends Thread {
private Plane p;
int i = 1;
public Dispatcher(Plane p) {
this.p = p;
}
@Override
public void run() {
while (true) {
if (i % 2 == 1) {
p.move1();
i++;
} else {
p.move2();
i++;
}
}
}
}
static class Renderer extends Thread {
private Plane p;
public Renderer(Plane p) {
this.p = p;
}
@Override
public void run() {
while (true) {
p.renderer();
}
}
}
}
这个问题非常容易解释,在Plane的renderer方法中调用了getX()和getY(),虽然getX()和getY()都进行了同步,但getX()方法结束释放锁后,Dispatcher线程又有机会并有可能对Plane的x,y进行修改,如果getY()紧接着获得锁,那么getY()就一定会读取到后来修改的这个y,虽然我们不会获取一个无中生有的值,但很显然这和我们的预期不一样,getX()和getY()应该是原子的,synchronized可以保证原子性和可见性,我们可以对renderer方法下修改。
注:下面会解释为何是一定会
public void renderer() {
int _x;
int _y;
synchronized (this) {
_x = getX();
_y = getY();
}
System.out.println("飞机的X坐标"+ _x + "飞机的Y坐标" + _y);
}
OK,此时已经没有问题了,把getX()和getY()放在了同步块中,之所以把System.out.println()写在外面是因为如果是真正的渲染操作那么一定是个耗时操作,如果耗时操作不需访问共享的状态变量那么尽量不要把耗时操作放在同步代码块中,这会导致一个线程长期尺有所锁,而其他也想获得这个锁的线程必须排队长期等待了。
现在可以引入happened-before的概念了,happened-before非常容易理解,当满足happened-before原则时,一个动作对内存施加的操作会被立即看到。这是JMM(java内存模型)对不同操作系统硬件抽象后做出的保证。
(1)同一个线程内的操作,源代码前面的操作一定会被后面的操作看到
如在主线程中
public static void main(String[] args) {
int i = 0;
i = 5;
System.out.println(i);
}
这就说System,out,println对 i 的访问一定可以看到5,而不是0,呵呵,这看起来应当是理所当然的,但现代处理器会对指令进行重排序以充分利用多核处理器(在保证结果正确的前提下,代码的书写顺序并不一定是执行顺序),这对单线程程序没什么影响,但给多线程编程带来了麻烦,所以对多线程的编程不能想当然,应该时刻保持怀疑的态度。
public class Five {
private static float money = 0;
private static boolean isObey = true;
public static void main(String[] args) {
new FiveCent().start();
isObey = false;
money = 0.5f;
}
static class FiveCent extends Thread {
@Override
public void run() {
while (!isObey) {
Thread.yield();
}
System.out.println("社会主义好 == " + money);
}
}
}
有可能(虽然非常难以重现)程序会输出了 "社会主义好 ==0.5“ ,这说明在FiveCent运行的那个线程中看到了主线程对money的写入操作money =0.5f ,却没有看到对isObey的写入操作,isObey=false,这就是重排序问题,此时我们可以使用恰当的同步来抑制重排序...
(2)同一个锁的 unlock happened-before lock
(3)传递性,如果A happens-before B , B happens-before C,那么A happens-before C
好了,有了这三条原则已经可以解释很多多线程问题了,现在结合这3条原则来解释那个一定会的问题。
首先,Dispatcher线程先获得Plane的内部锁也就是lock,然后对x,y进行写操作,然后unlock, 这是在一个线程内发生的符合原则一。可以得出对x,y的写操作happened-beforeunlock。
然后,getY()得到Plane的内部锁lock,然后是一个对y的读操作,然后unlock。根据原则一可以得到 lock happened-before y的读操作,根据原则二得到 unlocak happens-before lock,再根据原则三也就是传递性得到,对x,y的写操作 happens-before y的读操,也就是说,x,y的写操作一对会被y的读操作正确观测到,这就解释了一定会的问题。
有了基本的概念,下篇文章会说下如何构建线程安全的类。