22丨高效率,从超越线程同步开始! 线程的同步是学习一门编程语言的难点。刚开始线程同步的困难,主要在于了解技术;跨过了基本技术的门槛后,更难的是掌握最基本的概念。

学习技术时,我们对基本概念熟视无睹,只想将宝剑尽快握在手,哪管宝剑何时该挥出的教导。学会技术后,基本概念就会回来找我们算旧账,出错一次剑,就记一笔账。账本慢慢变厚的过程,也是我们向基本概念靠拢的过程。当我们掌握了最基本的概念后,开始慢慢还账,账本再越变越薄。

不单单是线程和同步,掌握好基本概念,几乎是我们学习所有技术背后的困境。这怨不得我们自己,我们认识一件事情的过程,大抵就是这样。

如果有人很早地就敲着桌子,不厌其烦地重复着基本的概念,事情会不会容易一些?这一次,我们聊聊线程同步的基本概念,以及如何超越线程同步。

什么时候需要同步?

线程有两个重要的特征,就是并发执行和共享进程资源。

你可以把进程想象成一个鱼缸。鱼缸里的金鱼可以看作线程。鱼缸里的碎石、水草、鱼食等可以看作共享的资源。每一条鱼都独立行动,随时可以吐个气泡,吃点鱼食,耍弄下水草。

鱼缸里的碎石、水草,小鱼儿搬不走、吃不掉,是一个不变的量。鱼食和气泡就不一样了,每一条小鱼儿随时都会吐泡泡、吃鱼食,改变气泡和鱼食的数量。鱼食和气泡,是鱼缸里的可变量。

如果有一条小鱼儿,想要数数有多少气泡,麻烦就来了,小鱼儿要吐出新泡泡,水面的旧泡泡要破掉,怎么数都跟不上变化的节奏。怎么办呢?要让变化停止,数清楚之前,其他的小鱼儿不能吐新泡泡,水面的泡泡也不能破掉。数清楚后,再恢复行动。这就像是线程的同步

线程的并发执行和共享进程资源,是为了提高效率。可是线程间如何管理共享资源的变化,却是一个棘手的问题,甚至是一个损害效率的问题。如果有两个以上的线程,关心共享资源的变化,一旦共享资源发生变化,就需要同步。线程同步的意思,就是一个接一个来,上一个线程没有完成一项任务之前,下一个线程不能开始相关的行动。简单地说,就是排队。

什么时候需要同步呢?需要同步的场景,要同时满足三个条件:

  • 使用两个以上的线程;
  • 关心共享资源的变化;
  • 改变共享资源的行为。

同步是损害效率的

假设一条小鱼吐一个泡泡1秒钟,如果没什么限制,10条小鱼1秒钟就可以吐10个泡泡。可是,如果要小鱼排队吐泡泡,10条小鱼1秒钟最多只能吐1个泡泡,这还没算上小鱼儿交接的时间。实际上,10条排队的小鱼1秒钟可能只能吐0.9个泡泡,因为交接也是要费时间的。

线程同步也是这样的,同步需要排队,同步的管理需要时间。所以,实践中,我们要想尽办法避免线程的同步。如果实在难以避免,就减少线程同步的排队时间。

避免线程同步

该怎么避免线程同步呢?

对应上述的同步场景所需的三个条件,我们只要打破其中的任何一个条件,就不需要线程同步了:

  • 使用单线程;
  • 不关心共享资源的变化;
  • 没有改变共享资源的行为。

举个例子吧,下面的这段代码用于表示在不同的语言环境下,该怎么打招呼。在汉语环境下,我们说“你好”;在英语环境下,我们说”Hello”。

如果只有一个线程,这段代码就没有问题。但是,如果有两个线程,一个线程读,一个线程写,就会出现竞争状况,返回不匹配的语言环境和问候语。 class HelloWords { private String language = “English”; private String greeting = “Hello”; void setLanguage(String language) { this.language = language; } void setGreeting(String greeting) { this.greeting = greeting; } String getLanguage() { return language; } String getGreeting() { return greeting ; } }

比如说,如果两个线程的执行顺序是:

  • 线程1执行getLanguage(),得到返回值是英语的语言环境;
  • 线程2执行setGreeting(),把问候语设置为汉语环境的“你好”;
  • 线程1执行getGreeting(),得到返回值是问候语“你好”。

那么,按照线程1得到的结果,在英语环境下,我们打招呼用“你好”。这可差的远了。

怎么改变这种状况呢? 其中一种方法,就是要把变量,变成像鱼缸里的碎石、水草这样的不可变的东西。不可变(immutable),放在软件环境里,指的就是一旦实例化,就不再改变。思路就是把变化放在出品之前。做到这一点的利器,就是Java的关键字“final”。 class HelloWords { private final String language; private final String greeting; HelloWords(String language, String greeting) { this.language = language; this.greeting = greeting; } String getLanguage() { return language; } String getGreeting() { return greeting ; } }

使用了限定词“final”的类变量,只能被赋值一次,而且只能在实例化之前被赋值。这样的变量,就是不可变的量。如果一个类的所有的变量,都是不可变的,那么这个类也是不可变的。

不使用限定词“final”,能不能达到不可变的效果呢?如果我们把上面代码中的限定词“final”删除掉,代码实现的细节依然保证这两个变量具有不可变的效果。 只是,如果代码再长一点,方法再多一点,我们可能会不经意地修改这两个变量,使得这个类又重新面临线程同步问题。

所以,我们要养成一个习惯,看到声明的变量,就要琢磨,这个变量能不能声明成不可变的量?现有的代码设计,这个变量如果不是不可变的,我们也要琢磨,有没有办法修改接口设计或者实现代码,把它改成不可变的量?设计一个类时,要优先考虑,这个类是不是可以设计成不可变的类?这样就可以避免很多不必要的线程同步,让代码的效率更高,接口更容易使用。

如果这是一个开放的不可变的类,我们要在接口规范里声明这个类是不可变的。这样调用者就不用考虑多线程安全的问题。没有声明多线程安全,或者不可变的接口,都不能当作线程安全的接口使用。

这是一个即便是资深的Java专家,也容易忽视的用法。希望你学会使用final限定词,让设计的接口又好用,又有效率。

减少线程同步时间

减少线程同步的排队时间,换一个说法,就是减少同步线程的阻塞时间。

比如说吧,如果小鱼吐泡泡需要同步,吐泡泡的时间越短越好。如果把吐泡泡的整个过程分成三步,吸气、吐泡、呼气,每一步用时1/3秒。如果排队轮到一条小鱼儿吐泡,它要完成所有三步,才轮到下一条小鱼,那么这个阻塞时间就是1秒。如果轮到这个小鱼儿吐泡时,它已经完成了吸气的动作,吐完泡就让给下一条等待吐泡的小鱼,离开队伍后再呼气,那么这个阻塞时间就是1/3秒。

在阻塞的这段时间里,做的事情越少,阻塞时间一般就会越短。

这个小鱼吐泡泡的过程,可以表示成如下的代码:

- 从这段代码里,我们可以看到,减少阻塞时间的一个办法,就是只同步和共享资源变化相关的逻辑。引起共享资源变化的事前准备以及善后处理,属于线程内部变化,不需要同步处理。

在设计接口或者实现代码时,有一项很重要的一个工作,就是反复考虑在多线程环境下,怎么做才能让线程同步的阻塞时间最小。这是一个很值得花费时间去琢磨的地方。比如上面小鱼吐泡泡的微小改进,效率就提高了三倍。

小结

今天,我们主要讨论线程同步的基本概念以及超越线程同步的技巧。由于线程同步对效率的损害,我们使用线程同步的最高技巧,就是不使用线程同步。如果做不到这一点,在线程同步的处理时间内,做的事情越少越好。

线程同步本身非常复杂,它相关的技术也很繁杂。这方面可以参考的书籍和文章也很多。我们不在这里讨论这些同步的技术了。

欢迎你在留言区,讨论这些技术,分享你使用这些技术的心得体会,我们一起来学习、精进。

一起来动手

下面的这段代码,摘录自OpenJDK,我们上次使用过。上一次,我们讨论了它的接口设计问题。

代码中Signature这个类,不是一个天然的多线程安全的类,它的setParameter(),initSign(),update()这些方法,都可以改变实例的状态。

如果要你去实现一个多线程安全的子类,你会怎么办?

如果要你重新设计这个类,包括拆分成几个类,你有没有办法把它设计成一个天然的多线程安全的类?

你试试看,能不能解决这些问题。欢迎你把发现的问题,解决的办法,以及优化的接口公布在讨论区,也可以写一下你的解决问题的思路。Signature这个类,是一个有着二十多年历史的,被广泛使用的Java核心类。说不定,你可以为OpenJDK社区,提供一个有价值的参考意见或者改进方案。 //* /* Copyright (c) 1996, 2018, Oracle and/or its affiliates. All rights reserved. /* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. /* /* /*/ package java.security; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.SignatureException; import java.security.SignatureSpi; import java.security.spec.AlgorithmParameterSpec; //*/* /* The Signature class is used to provide applications the functionality /* of a digital signature algorithm. Digital signatures are used for /* authentication and integrity assurance of digital data. /* /* /* /* @since 1.1 /*/ public abstract class Signature extends SignatureSpi { // snipped //*/* /* Initializes this signature engine with the specified parameter set. /* /* @param params the parameters /* /* @exception InvalidAlgorithmParameterException if the given parameters /* are inappropriate for this signature engine /* /* @see /#getParameters /*/ public final void setParameter(AlgorithmParameterSpec params) throws InvalidAlgorithmParameterException { // snipped } //*/* /* Initializes this object for verification. If this method is called /* again with a different argument, it negates the effect /* of this call. /* /* @param publicKey the public key of the identity whose signature is /* going to be verified. /* /* @exception InvalidKeyException if the key is invalid. /*/ public final void initVerify(PublicKey publicKey) throws InvalidKeyException { // snipped } //*/* /* Initialize this object for signing. If this method is called /* again with a different argument, it negates the effect /* of this call. /* /* @param privateKey the private key of the identity whose signature /* is going to be generated. /* /* @exception InvalidKeyException if the key is invalid. /*/ public final void initSign(PrivateKey privateKey) throws InvalidKeyException { // snipped } //*/* /* Updates the data to be signed or verified, using the specified /* array of bytes. /* /* @param data the byte array to use for the update. /* /* @exception SignatureException if this signature object is not /* initialized properly. /*/ public final void update(byte[] data) throws SignatureException { // snipped } //*/* /* Returns the signature bytes of all the data updated. /* The format of the signature depends on the underlying /* signature scheme. /* /* <p>A call to this method resets this signature object to the state /* it was in when previously initialized for signing via a /* call to {@code initSign(PrivateKey)}. That is, the object is /* reset and available to generate another signature from the same /* signer, if desired, via new calls to {@code update} and /* {@code sign}. /* /* @return the signature bytes of the signing operation's result. /* /* @exception SignatureException if this signature object is not /* initialized properly or if this signature algorithm is unable to /* process the input data provided. /*/ public final byte[] sign() throws SignatureException { // snipped } //*/* /* Verifies the passed-in signature. /* /* <p>A call to this method resets this signature object to the state /* it was in when previously initialized for verification via a /* call to {@code initVerify(PublicKey)}. That is, the object is /* reset and available to verify another signature from the identity /* whose public key was specified in the call to {@code initVerify}. /* /* @param signature the signature bytes to be verified. /* /* @return true if the signature was verified, false if not. /* /* @exception SignatureException if this signature object is not /* initialized properly, the passed-in signature is improperly /* encoded or of the wrong type, if this signature algorithm is unable to /* process the input data provided, etc. /*/ public final boolean verify(byte[] signature) throws SignatureException { // snipped } }

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e4%bb%a3%e7%a0%81%e7%b2%be%e8%bf%9b%e4%b9%8b%e8%b7%af/22%e4%b8%a8%e9%ab%98%e6%95%88%e7%8e%87%ef%bc%8c%e4%bb%8e%e8%b6%85%e8%b6%8a%e7%ba%bf%e7%a8%8b%e5%90%8c%e6%ad%a5%e5%bc%80%e5%a7%8b%ef%bc%81.md