关于synchronized

什么是同步

对于Java程序员来说,synchronized关键字肯定都不陌生了。最近由于培训新员工的原因,自己在复习Java中多线程的知识,看到synchronized关键字的时候,想到它的中文翻译“同步”,脑子里突然冒出一个问题:“到底什么是同步?”停下来想了一下,似乎同步就是对数据的并发访问进行保护,以免出现数据错误。那么IO中的同步IO也是防止数据被并发访问吗?好像不是,因为同步IO是指有IO数据返回前,线程会被挂起、阻塞住,这里的同步主要体现的是阻塞性,好像不涉及到对数据的保护。这些似乎都是同步,但感觉自己又没法用一句完整的话将它描述出来!看来肯定是有什么东西自己还没有掌握透彻,亦或是某些知识点还没有总结到位,这才出现这种似懂非懂的状况。索性花点时间再专研一下吧。

那么,什么是同步呢?
习惯性的先百度了一下,看到百度百科里是这样解释的:

同步指两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。
同步(英语:Synchronization),指对在一个系统中所发生的事件(event)之间进行协调,在时间上出现一致性与统一化的现象。在系统中进行同步,也被称为及时(in time)、同步化的(synchronous、in sync)。

嗯,说的挺抽象的。那再看看维基百科是怎么解释的呢?

Synchronization is the coordination of events to operate a system in unison
(同步是对事件的协调以使得系统和谐一致)

嗯,一样挺抽象的~感觉就是读的懂这些字,但就是不知道它们在说什么。既然这些解释都太抽象不好理解,那就先找些它的具体例子实例化一下吧。有些什么例子呢?最近做的项目中,涉及到两个系统间的告警数据同步,这里就出现了“同步”这个词了。还有其他例子吗?前段时间我换了手机,需要把原来手机上的通讯录导到新手机里,当时下了个QQ同步助手,两分钟搞定,嗯,这里也出现了“同步”。还有,以前看美剧的时候,常常有画面和声音不同步的情况,那么反过来正常的情况就是声画同步,看起来这也是一种“同步”。另外,我们常常会说在进行一项活动时,需要另一项相关的活动同步进行,比如“促进工业化、信息化、城镇化、农业现代化同步发展”,这又是一个“同步”的例子。

看看这些例子,除了都符合前面“同步”的抽象定义外,感觉说不上它们之间有什么共性。每个例子所处的领域差别很大。这时正好看到维基百科里对同步的进一步说明,发现下面的话:

Synchronization is an important concept in the following fields:

  • Computer science
  • Cryptography
  • Multimedia
  • Music
  • Neuroscience
    ...

突然意识到我提出“什么是同步”的问题时,忽略掉了它所在的领域和上下文。其实,同步这个词在不同的领域有差别很大的具体解释。例如通信领域中的同步含义就很广泛,具体到网络通信可能指的是一个操作要阻塞直到其完成后才能继续下一步操作(个人理解IO其实就是一种通信问题,因而IO中的同步可以等同理解为阻塞)。如果抛开具体的领域来说什么是同步,就只能得到那种包罗万象、抽象又不具体的解释。

回过头来再看看我的问题,其实应该更具体的这样问:在java的多线程中同步是什么。

计算机科学中的同步是什么?

在回答java多线程中什么是同步之前,先看看在计算机科学中,什么是同步。因为前者是后者的一个子领域,理解了计算机这个大领域中什么是同步,可以帮助理解java线程这个小领域中的同步。

维基百科中是这样解释计算机科学领域中的同步的:

In computer science, synchronization refers to one of two distinct but related concepts: synchronization of processes, and synchronization of data. Process synchronization refers to the idea that multiple processes are to join up or handshake at a certain point, in order to reach an agreement or commit to a certain sequence of action. Data synchronization refers to the idea of keeping multiple copies of a dataset in coherence with one another, or to maintain data integrity. Process synchronization primitives are commonly used to implement data synchronization.

计算机科学中,同步涉及到两个各自独立、但又相互关联的概念:进程的同步,以及数据的同步

这句话我觉得值得牢记在心里!不过上面引文中的最后一句话也很重要:

进程同步原语通常用于实现数据同步

我觉得这第二句话说明了进程同步和数据同步的关联关系。

进程的同步就是本文要讨论的问题域,具体来说是java中多线程的同步。而数据同步也很好理解,例如mysql的主从数据同步,QQ同步助手的通讯录同步等都符合上面的数据同步定义,即数据集的多份拷贝之间保证彼此之间的一致性和完整性。

虽然维基百科的解释不一定是最权威的解释,不过我觉得在计算机领域按进程和数据两种对象来细分同步还是有道理的。这两个领域中同步的具体形式完全不同,而又彼此有联系。如果不能清楚的意识到这种即不同又相关的特点,很容易因为两者的相关性从而下意识的把不同的概念混淆在一起。

那么接下来就看看具体到java里面,是如何使用synchronized关键字来实现线程的同步吧。其实这个讨论的过程中也会涉及到数据的同步,后面会具体说明。

Java中线程的同步

提到Java中的线程同步,就不得不提操作系统中的进程同步,这两者的问题域是极其相似的。同步实际上就是对并发执行的进程或线程进行某种协调,而协调的具体手段有不止一种,需要根据协调的具体要求来使用对应的手段。从这点来看,并发是引入同步的起因,而要实现同步,则需要具体的一些方法

那我们首先看下并发的线程面临哪些问题场景(为了少写一些字,后面就省略进程二字),了解了这些并发的场景就知道了参与到这些场景中的线程需要哪一类的协调方式,该选择哪种具体的同步手段。实际上,并发的多个线程之间,会存在下面两种协作关系:

  • 竞争
  • 合作

对于竞争,相信大家都很熟悉了。就是多个线程会争抢某种在线程之间共享的资源,这类资源叫做临界资源,而使用临界资源的那部分代码块叫做临界区。例如下面的Java代码:

public class Counter {
    private int count;

    public void increase() {
        count++;
    }

    public static void main(String[] args) {
        final Counter counter = new Counter();

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                // counter是临界资源
                synchronized (counter) {
                    // 这段代码块是临界区
                    counter.increase();
                }
            }
        });
    }
}

而协调线程之间的竞争关系,则是提供互斥这种同步手段来实现的。互斥就是当一个线程在临界区访问临界资源时,其他线程不能进入该临界区访问任何临界资源。也就是说互斥提供的同步手段就是让线程顺序的依次使用临界资源。而Java通过synchronized关键字提供了互斥的能力。

而对于合作相信大家也不陌生。例如经典的生产者-消费者问题就是一种线程间合作的例子。而对合作的线程进行协调,其中一种方式就是提供线程间的相互通信能力。如果更抽象一点的形容这种通信方式,那就是多个并发执行的线程之间存在某种消息传递机制,使得某些线程执行到一个特定点后,可以给另一些线程发送消息,以通知它们开始执行。而Java多线程对通信的支持则通过Object中的wait()、notify()、notifyAll()等方法完成,而这些方法必须被包含在一个synchronized代码块中:

synchronized (lock) {
    try {
        lock.wait();
    } catch (InterruptedException e) {

    }
}


synchronized (lock) {
    lock.notify();
}

看,线程间的通信也需要synchronized关键字的参与,因此说通信也是一种具体的同步手段也不为过。

总结一下,同步指的就是对线程执行的相互关系进行协调,它源于线程并发执行的引入。并发线程存在竞争和合作两种关系,我们可以通过提供互斥和通信这两种同步手段来对前面两种关系提供支持。

事实上,正如前面说的那样,在操作系统中也是通过提供互斥和通信这两种手段,来解决并发进程的同步问题。而操作系统中提供互斥、通信手段的具体实现有很多种,例如:

  • 管程 Monitor
  • 信号量 Semaphore
  • 消息传递 Message Passing
  • ...

实际上,Java语言的设计、JDK API的实现、以及JVM虚拟机的规范中关于线程的同步,就采用了一些上面操作系统中对进程同步的解决方案。

例如,前面的synchronized关键字其实现方式就是用的管程Monitor。如果我们用javap -c命令看下前面代码的class文件,会得到类似下面的字节码:

public void run();
    Code:
       0: aload_0
       1: getfield      #1                  // Field val$counter:Lcom/leo/base/javas/jvm/Counter;
       4: dup
       5: astore_1
       6: monitorente
       7: aload_0
       8: getfield      #1                  // Field val$counter:Lcom/leo/base/javas/jvm/Counter;
      11: invokevirtual #3                  // Method com/leo/base/javas/jvm/Counter.increase:()V
      14: aload_1
      15: monitorexit
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit

注意其中的第6、21行,分别代表了管程的进入和退出。

另外,信号量Semaphore也通过jdk中的java.util.concurrent.Semaphore类提供了实现。其他的还有诸如CyclicBarrier等就不一一列举了。

而一旦我们知道了诸如管程、信号量、消息传递、互斥锁这些具体的手段都是用于提供线程同步的互斥、通信能力的话,在开发中就能很容易的知道应该在哪些场景下来如何使用这些手段了。

把知识点连起来,形成体系

在查阅这些线程同步的知识的过程中,心里还隐隐有另一个问题。以前学习过Java内存模型的相关知识,知道在Java多线程中,数据是存储在Java的主内存中的,也就是Java堆里面。而在每个线程使用这些数据时,需要把堆中的数据拷贝到线程的工作内存中,也就是栈中。那么Java通过synchronized的关键字可以保证线程对数据的修改结果在同步代码块结束后被其他线程看到。这也就是说线程必定需要在运行到同步代码块的某一个点的时候,将线程栈中的数据拷贝回主内存的堆中,否则下一个获得同步代码块执行权的线程就看不到上一个线程修改的数据了。

那么问题来了,具体的细节上,Java是怎么做的这个动作的呢?

这个问题冒出来的时候,自己马上联想到之前在阅读《深入理解Java虚拟机》这本书的时候,看到书里提到过Java内存模型中定义的8个原子的操作,用于规范主内存和工作内存之间的交互协议,这些协议涉及到一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这样的细节。(大家注意到没,这里就出现了前面说到的计算机领域中的另一类同步 - 针对数据的同步。果然线程同步和数据同步是相关的)

这8个原子操作分别是:

  1. lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  2. unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  6. assign:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存进一步规定了这8个操作的执行时必须满足的规则。其中有两条:

如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值

对一个变量执行unlock操作之前,必须先把此变量同步回主内存中

前一条说明了Java线程在进入同步代码块时,是怎样从主内存拷贝数据到工作内存的。后一条则回答了同步代码块执行结束时,是怎样把工作内存的值拷贝回主内存的。

当时在看这部分内容时,虽然理解了这些操作的作用、规则机制,但是感觉比较抽象,时间一久就很模糊了。此时想起来,通过synchronized这个具体的知识点,将Java内存模型机制和多线程实现这两者之间又建立了一个连接,感觉对自己的知识体系做了进一步完善。而体系中各个点的连接越多,一来对知识的理解更深,二来也越不容易忘记。

关于模式的复用

从上面我们可以看到,java中对多线程并发的同步采用了很多操作系统进程同步的解决方案。而这些方案、模式之所以可以复用过来,就在于线程同步和进程同步这两个具体领域背后的问题模型是一样的,因而一个领域的解决方案可以形成模式,并复用到新的具体领域。

那这给了我什么启发呢?我觉得有两点。

  • 面对新的具体问题时,先看看能不能复用模式

当我们遇到新领域、新问题后,如果能抛开问题的表象,看到具体问题背后的本质、模型,这样就可以优先想一想,对这种问题模型是否存在已经成熟的解决方案可以直接复用或是参考借鉴呢?有了这样的思维方式和看待问题的角度,相信在不少时候都可以极大的提升我们解决问题、学习新知识的效率。

例如,对于分布式并行计算中的同步问题,从具体载体的形式上来讲,并行的是各个计算机,而不是进程和线程了,因此问题的表象发生了变化。但是问题的基本模型还是在讨论并发中的同步,也就是问题本质和模型没有变化。那么当我们学习这个新领域时,是否可以借鉴线程和进程同步中得到的经验来帮助我们更快速的理解呢?在实施并行计算的工程应用时,是否也可以用到类似管程、信号量这些手段呢?

再比如,看过《数学之美》这本书的同学大概知道早期的机器翻译的解决办法是人工构建语法词法规则,但是效果很差。后来有人将这个问题的模型进行重新定义,把它变成一个数学上的概率问题,这样一来语言翻译问题从本质和模型上来讲就是一个数学问题了,就可以转而用数学方式进行处理,具体来说就是利用数学上的隐含马尔科夫模型。隐含马尔科夫模型早在机器翻译之前就被提出来了,当将它应用到机器翻译领域后,翻译的准确度得到极大的提高。此外,隐含马尔科夫模型还应用在了语音识别、图像处理、基因序列分析、甚至是股票预测和投资。从这可以看到这些不同形式的问题背后,其本质和模型都是一样的,因而其具体的解决手段也可以有相通的地方。

我想,有了这种思维方式,我们其实可以在生活的方方面面使用到它。最近我在一个育儿专栏,里面提到如何引导害羞的孩子参与到社交活动中,其实里面的理念和方法用到成年人身上也是类似的。而这就是一个在生活中使用上面思维方式的例子。

  • 对具有共性的不同问题进行总结,提取问题模型和通用解决方案

上面是将问题模型应用到具体的领域。其实反过来,我们也应该注重从具体问题抽取共性,看看是否可以总结出具有普遍意义的问题模型,进而在理论上研究一些形而上的解决方案。当然,这项工作对于像我这样搞工程应用的人来说很难做到,也缺乏时间进行理论性的研究,往大了说,是搞科学研究的那些人擅长的事。但是若我们具有这种意识,哪怕是我们从中能得到一点点有用的可复用的模型,对我们的工作、学习都是有好处的。

举个例子,前几个月遇到一个故障,一个系统上的某些定时任务没有执行,而其他时间点的任务都触发执行了。最终分析的原因是有些定时任务中进行了网络IO操作,而网络IO设置的超时时间较长,超过了下一次任务的触发时间点,进而使得本该执行的下一个定时任务被错过了。当时我觉得这是一个典型问题,因此留心大致做了一下总结:在定时任务中(尤其是触发频率较高的定时任务中),如果涉及到IO,那么一定要小心这些IO操作的执行时间会不会影响下一次任务的触发和执行。而恰巧最近团队在做一个新需求时,也涉及到类似的场景,只是差别在于触发定时任务的是外部系统,我们只是对任务执行做二次开发。在我们的实现中要对多个网络设备进行访问,如果这时把这些网络IO操作都放到任务触发时来做,而且是串行的来做,就很有可能出现之前那种问题。还好有了上次的经验,总结了问题的本质,因而在这次开发中及时对实现方案进行了修改,规避了潜在的风险。

从这个例子看,有意识的从具体问题中总结共性、模型,对我们还是有帮助的吧。

再进一步,我觉得拥有上面这两种思维方式可以提高一个人的核心能力。因为这种能力在人的一生当中的方方面面都是用得上的,即便这个人跨界到其他领域当中,这些思维方式也有用,而不像具体的知识、技能那样就失效了。什么意思呢?我们常常会看到一些很有能力的人,他们在各种工作岗位、担任不同的角色时都能做得很好,即使这些岗位、角色涉及的领域有很大的不同。比如搞开发他能做得好,转型到管理他也可以胜任。甚至在跨学科中也有人能达到这样的境界,例如达芬奇、牛顿、爱因斯坦。与其说是他们聪明,倒不如说他们掌握了好的思维方式。这些思维方式可以帮助他们迅速的适应新领域,学东西比别人快,看问题比别人透彻。我觉得作为个人成长,我们除了学习具体的知识和技术外,掌握这些思维方式也是很重要,而且这些思维方式、方法更通用。

后记

通过这次温习Java中同步相关知识的过程,个人感觉收获的不仅是具体的技术、知识,还在于修炼了一些解决问题、提高工作学习效率的方法。个人总结一下有下面几点:

  1. 在面对一个问题时,首先应该确定清楚问题的具体领域、范围。如果问题域不清不楚,不仅方向容易走偏,得不到正确结果,甚至在你误打误撞得到了比较正确的结果时,自己心里也是没底的,也说不清楚到底是怎么一会事儿。
  2. 构建知识体系,将各个离散的知识点通过建立连接的方式联系起来,能极大的帮助理解和记忆。
  3. 工作、学习中不仅要解决具体的问题、掌握具体的知识,还要注意总结一些形而上的道理,尝试去看到问题的本质。这些形而上的东西就像是中国传统所说的道,如果一个人得了道,那他再去学习、使用具体的术的时候就会更快、更好。这就像《倚天屠龙记》中的张无忌一样,练会了九阳神功后内功了得,其他具体的武功招式在他眼里都一目了然、没了秘密,一看就懂,一学就会。当然,对于我们这些搞工程应用的人来说,光悟了道而没掌握到具体的术也不行。还是拿张无忌来类比,虽然他练了九阳神功后内功了得,但是刚出师的时候遇到灭绝师太这样的高手,身上不会一招半式,也没有实战经验,还是被逼得身处下风。因此内外兼修至关重要。
  4. 学习掌握好的思维方式可以受用一生,即使环境大变,也有办法去应对。

推荐阅读更多精彩内容