1

在之前学习编程的时候,有一个概念根深蒂固,即程序=算法+数据结构。数据代表问题空间中的客体,代码就用来处理这些数据,这种思维是站在计算机的角度去抽象问题和解决问题,称之为面向过程编程。后来逐渐的发展,诞生了面向对象的编程思想。面向对象是站在现实世界的角度去抽象解决问题,把数据和行为都看成对象的一部分。

有了面向对象的编程模式,极大的地提升了现代软件的开发效率和规模,但是现实世界和计算机世界还是有很大的差异。比如人们很难想象在现实世界中进行一项工作的时候,不停的中断和切换,某些属性也会在中断期间改变,而这些事件在计算机里是很正常的。因此不得不妥协,首先在保证数据的准确性之后,才能来谈高效。

1 什么叫线程安全

我们谈论的线程安全,是限定在多个线程之间存在共享数据访问,因为如果一段代码根本不会与其他线程共享数据,那也就不会出现线程安全问题。

当多个线程访问一个对象的时候,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他协调操作的时候,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

也就是当一个对象可以安全的被多个线程同时使用,那么它就是线程安全对象。

2 java线程安全

按照线程安全的安全程度来分的话,java中的各种操作共享的数据主要分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

2.1 不可变

在java中,不可变(immutable)的对象一定是线程安全的。

对于final关键字可见性来说,只要一个不可变对象被正确构建出来,那其外部的可见状态永远也不会改变。不可变带来的安全性是最简单和最纯粹的。

对于基本数据类型来说,只需要用final修饰即可。

对于对象来说,将对象中带有状态的变量都设置为final。

在java api中复核不可变要求的类型主要有:

  • String
  • 枚举类型
  • Long和Double等数值包装类型
  • BigInteger和BigDecimal等大数据类型

AtomicLong和AtomInteger等原子类并非是不可变的类型。

2.2 绝对线程安全

一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”这个条件,通常是需要付出很大,甚至是有些不切实际的代价。

在java里标注自己是线程安全的类,大多都不是绝对线程安全,比如某些情况下Vector类在调用端也需要额外的同步措施。

2.3 相对线程安全

这个就是我们通常意义所说的线程安全。

它需要保证对这个对象单独的操作时线程安全的,我们在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

如vector,hashtable等线程安全类都是属于这种的。

2.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确使用同步手段来保证对象在并发环境中可以安全使用。

java中的ArrayList和HashMap就是这种。

2.5 线程对立

指无论调用端是否采用同步措施,都无法在多线程环境中并发使用代码。

一个例子就是Thread类的suspen方法和resume方法,如果有两个线程同时持有一个线程对象,一个去中断线程,一个去恢复线程,如果并发进行的话,无论调用是否采用了同步,都会存在锁死的风险。

常见的线程对立例子还有:

  • System.setIn()
  • System.setOut()
  • System.runFinalizersOnExit()

3 线程安全的实现方法

如何实现线程安全与代码编写有着很大的关系,但是虚拟机提供的同步和锁的机制也起到了非常重要的作用。

3.1 互斥同步

互斥同步是一种比较常见的并发正确性保障手段。

同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或是一些,使用信号量的时候是一些)线程使用。互斥是实现同步的一种手段,临界区、互斥量和信号量等都是主要的互斥手段。

  • synchronized

    java中最基本的互斥同步手段就是synchronized关键字。

    synchronized关键字在经过编译之后,会在同步块的前后形成monitorenter和monitorexit这两个字节码指令。这两个字节码都需要一个引用类型的参数来指明锁定和解锁的对象。如果在java程序中指明了这个对象,那么这个参数就是此对象的引用,如果没有指定,那就根据synchronized修饰的是实例方法还是类方法来取对应的对象实例或者Class对象来作为锁对象。

    • synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
    • synchronized同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
  • ReentrantLock

    java.util.concurrent包中提供了重入锁来实现同步。

    ReentrantLock在写的时候,使用lock()和unlock()方法配合try/finally来完成,相比synchronized增加了一些高级功能:

    • 等待可中断

      当持有锁的线程长期不释放锁的时候,正在等待的线程可以放弃等待,改为处理别的事情。

    • 可实现公平锁

      公平锁是指多个线程在等待一个同一个锁的时候,必须按照申请锁的时间顺序来依次获得锁,也就是队列方式。非公平锁则是竞争获取。

      synchronized是非公平的,ReentrantLock默认也是非公平的,但是可以实现公平的。

    • 锁可以绑定多个事件

      一个ReentrantLock对象可以绑定多个Condition对象,而synchronized的锁对象的wait、notify等方法只能实现一个隐含的条件。

如果要用到上面三个高级功能的话,建议使用ReentrantLock,但是如果基于性能考虑的话,优先考虑使用synchronized来进行同步。

在jdk1.6之前,synchronized在多线程下吞吐量下降很严重,ReentrantLock表现稳定。但是1.6之后,性能就差不多了,而且以后虚拟机的优化也是偏向synchronized。

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也是阻塞同步。从处理问题角度来讲,互斥同步是一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出问题,无论共享数据是否会出现竞争,它都会去加锁同步。

3.2 非阻塞同步

随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略。

通俗来讲就是,先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

最常见的补偿措施就是不断的重试,直到成功为止。

对于非阻塞同步来讲,最重要的一个硬件指令是比较并交换(CAS)。java的Unsafe类里面的某些方法被编译之后,就成了一条平台相关的处理器CAS指令,没有方法调用的过程。

Unsafe类不是提供给用户程序调用的类,不使用反射的话,只能通过使用其他java api来间接使用。java的concurrent包里的AtomicInteger整数原子类的compareAndSet和getAndIncrement方法使用了Unsafe类的CAS操作。

CAS操作会出现“ABA”问题:如果一个变量初始被读取是A,最终被赋值的时候检查到仍然是A,但是在读取和赋值这段时间里,有可能被其他线程改为B,后来又改成了A。那么CAS就认为没有改变过。大部分情况下ABA也不会影响程序并发的正确性。

3.3 无同步方案

要保证线程安全,不一定就得需要数据的同步,两者没有因果关系。如果一个方法不涉及共享数据,那它自然就不用同步,有些代码天生就是线程安全的,比如:

  • 可重入代码

    也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行别的代码(包括递归调用自己),而控制权返回后,原来的程序不会出现错误。

    所有可重入的代码都是线程安全的,但是线程安全的代码不一定是可重入的。

    可重入代码的一些特征是:

    • 不依赖存储在堆上的数据和公用系统资源
    • 用的状态量都是参数传入
    • 不调用不可重入代码

如果一个方法是结果是可预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性要求,当然也就是线程安全的。

  • 线程本地存储

    如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码能不能在同一个线程中运行?如果把这些共享数据的可见范围放在同一个线程之内,这样无需进行同步也可以做到线程安全。

    符合这种特点的应用有很多,比如:

    • 大部分的消息队列的生产者——消费者模式。
    • web交互模型中的一个请求对应一个服务器线程的处理方式。

在java中,如果一个变量要被某个线程独享,就可以用ThreadLocal来实现线程本地存储的功能。

4 写在最后

通过对线程安全的仔细研究,终于理解了函数式编程为什么是天然的支持高并发了。函数式编程里的不可变对象和可重入代码,都不会出现线程安全的问题。这也是为什么现在函数式编程越来越火的一个重要原因。


zhenfeng_zhu
19 声望1 粉丝

我要赚好多好多money~~