一步一步掌握线程机制(三)---synchronized和volatile的使用
现在开始进入线程编程中最重要的话题---数据同步,它是线程编程的核心,也是难点,就算我们理解了数据同步的基本原理,但是我们也无法保证能够写出正确的同步代码,但基本原理是必须掌握的。
要想理解数据同步的基本原理,首先就要明白,为什么我们要数据同步?
public class CharacterDisplayCanvas extends JComponent implements CharacterListener { protected FontMetrics fm; protected char[] tmpChar = new char[1]; protected int fontHeight; public CharacterDisplayCanvas() { setFont(new Font("Monospaced", Font.BOLD, 18)); fm = Toolkit.getDefaultToolkit().getFontMetrics(getFont()); fontHeight = fm.getHeight(); } public CharacterDisplayCanvas(CharacterSource cs) { this(); setCharacterSource(cs); } public void setCharacterSource(CharacterSource cs) { cs.addCharacterListener(this); } public synchronized void newCharacter(CharacterEvent ce) { tmpChar[0] = (char) ce.character; repaint(); } public Dimension preferredSize() { return new Dimension(fm.getMaxAscent() + 10, fm.getMaxAdvance() + 10); } protected synchronized void paintComponent(Graphics gc) { Dimension d = getSize(); gc.clearRect(0, 0, d.width, d.height); if (tmpChar[0] == 0) { return; } int charWidth = fm.charWidth((int) tmpChar[0]); gc.drawChars(tmpChar, 0, 1, (d.width - charWidth) / 2, fontHeight); } }
仔细查看上面的代码,我们就会发现,有两个方法的前面多了一个新的关键字:synchronized。让我们看看这两个方法为什么要添加这个关键字。
newCharacter()用于显示新字母,而paintComponent()负责调整和重画canvas。这两个方法存在着race condition,也就是竞争,因为它们访问的是同一份数据,最重要的是它们是由不同的线程所调用的,这就导致我们无法保证它们的调用是按照正确的顺序来进行,可能在newCharacter()方法未被调用前paintComponent()方法就已经重新绘制canvas。
之所以产生竞争,除了这两个方法访问的是同一份数据之外,还和它们是非automic有关。我们在初中的时候都学过,原子曾经被认为是最小单元,不可分的,哪怕现在已经证明这是不正确的,但原子不可分的概念在计算机这里保留了下来。 一个程序如果被认为是automic,那么就表示它是无法被中断的,不会有中间状态。使用synchronized,就能保证该方法无法被中断,那么其他线程就无法在该方法没有完成前调用它。
结合对象锁的知识,我们可以简单的讲解一下synchronized的原理:一个线程如果想要调用另一个线程的synchronized方法,而且该方法正在被其他线程调用,那么这个线程就必须等待,等待其他线程释放该方法所在的对象的锁,然后获得该锁执行该方法。锁机制能够确保同一时间只有一个线程能够调用该方法,也就能保证只有一个线程能够访问数据。
还记得我们之前通过使用标记来结束线程的时候,将该标记用volatile修饰?如果我们不用volatile,又能使用什么方法呢?
如果单单只是上面的知识,我们可能会想到利用synchronized来同步run()和setDone(),因为就是这两个方法在竞争done这个数据。但是这样存在很大的问题:run()会在done没有被设置true前永远不会结束,但是done标记却要等到run()方法结束后才能由setDone()方法进行设置。
这就是一个死锁,永远解不开的锁。
产生死锁的原因有很多,像是上面这种情况就是一个典型的代表,主要原因就是run()方法的scope(范围)太大。所谓的scope,指的是获取锁到释放锁的时间,而run()方法的scope是一个循环,除非done设置为true。这种需要依赖其他线程的方法来结束执行的方法,如果将整个方法设置为同步,就会出现死锁。
所以,最好的方法就是将scope缩小。
我们可以不用对整个方法进行同步,而是对需要访问的数据进行同步,也就是对done使用volatile。
要想理解volatile的工作原理,我们必须清楚变量的加载机制。java的内存模型允许线程能够在local memory中持有变量的值,所以这也就导致某个线程改变该变量的值时,其他线程可能不会察觉到该变量的变化。这种情况只是一种可能,并不代表一定会出现,但像是循环执行这种操作,就增加了这种可能。
所以,我们要做的事情其实很简单,就是让线程从同一个地方取出变量而不是自己维护一份。使用volatile,每次使用该变量都要从主存储器中读取,每次改变该变量时,也要存入主存储器,而且加载和存储都是automic,无论是否是long或者double变量(这两种类型的存储是非automic的)。
值得注意的,run()方法和setDone()方法本身就是automic,因为setDone()方法仅有一个存储操作,而run()方法也只有一个读取操作,其余部分根本就需要该值保持不变,也就是说,这两个方法其实本身就不存在竞争。
当然,如果还是坚持想要使用synchronized的话,倒是有个比较丑陋的方法:对done提供setter和getter,然后synchronized这两个方法,因为取得同步化的锁代表所有暂时存储于寄存器的值都会被清空到主存储器中,这样run()方法中要想取得done就必须等到setDone()方法设置完毕。
多么丑陋的实现啊!!就为了同步一个变量,结果我们就要平白对两个方法进行同步,增加无谓的线程开销!!但这也是没有办法的事,如果我们不知道还有volatile的话,没准还会为自己的小聪明而开心不已!!
这就是多线程编程的现实,如果我们无法知道还有更加优雅的实现,我们永远也只能写出这样的代码。
但让人更加困惑的是,volatile本身的存在现在也引起人们的关注:它到底有没有必要?
volatile是以moot point(未决点)来实现的:变量永远都从主存储器中读取,但这也只是JDK 1.2之前的情况,现在的虚拟机实现使得内存模式越来越复杂,而且也得到了极大的优化,并且这种趋势只会一直持续下去。也就是说,基于内存模式的volatile可能会因为内存模式的不断优化而逐渐变得没有意义。
volatile的使用是有局限的,它仅仅解决因内存模式而引发的问题,而且只能用在对变量的automic操作上,也就是访问该变量的方法只可以有单一的加载或者存储。但很多方法都是非automic,像是递增或者递减操作,就允许存在中间状态,因为它们本身就是载入,变更和存储的简化而已,也就是所谓的syntactic sugar(语法糖)。
我们大概可以这样理解volatile的使用条件:强迫虚拟机不要临时复制变量,哪怕我们在许多情况下都不会使用它们。
volatile是否可以运用在数组上,让整个数组中的所有元素都被同步呢?凡是使用java的人都会对这样的幻想嗤之以鼻,因为实际情况是只有数组的引用才会被同步,数组中的元素不会是volatile的,虚拟机还是可以将个别元素存储于local的寄存器中,没有任何方法可以指定数组的元素应该以volatile的方式来处理。
我们上面的同步问题是发生在展示随机数字与字母的显示组件,现在我们继续将功能完善:玩家可以输入所显示的字母,并且正确就会得分。