Java并发实在是一个很深的问题,这里只简单记录一下Java并发的知识点。水太深,如果不花大量的时间感觉完全hold不住,但是目前的精力完全不够,兴趣也不在这

什么是线程安全性

某个类的行为和其规范完全一致
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的

原子操作(Atomic Operation)

原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就会一直运行到结束,中间不会有任何的上下文切换,它是不可分割的。

举一个常见的例子:a++,这个操作就不是一个原子性的操作,那么在多个线程访问调用的时候,a的最终结果就很有可能不是我们的预期值。因为实际上a++这个操作可以分为已下三步:获取a的值,更新a的值,写回a的值。

缓存一致性

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调,在不同的处理器架构中提供了不同级别的缓存一致性。

这个缓存一致性可以通过volatile关键字来加深理解。

Volatile关键字

Volatile是一种较弱的同步机制,用来确保将变量的更新操作通知到其他线程,当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
但是有一点是需要注意的,被volatile修饰的变量的的操作也应该是原子性的,不然同样会出先问题。
例如:

Volatile int a = 0;

// 非原子性操作,使用volatile不能保证同步,改用Synchronized
a++;

而为什么Volatile能够实现这种功能呢?
这个要从它的实现原理说起,在x86处理器下通过工具获取JIT编译器生成的汇编指令来看Volatile的写操作实际上做了什么吧。

0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有Volatile变量修饰的共享变量进行写操作的时候会多第二行代码,lock指令修饰。
而lock指令会做什么事呢?

  • 将当前处理器缓存行的数据写回到系统内存
  • 该写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效

在上面介绍缓存一致性的时候提到了,在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调,在不同的处理器架构中提供了不同级别的缓存一致性。
那么这个时候也就有了下面的事情:
处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存中再进行操作,但操作完成后不确定什么时候会写回到内存,如果对声明了Volatile变量进行些操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。而由于缓存一致性协议,每个处理器会通过嗅探在总线上传播的数据在来检查自己缓存的值是不是国旗了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态。当处理器要对这个数据进行修改操作的时候,就会强制从系统内存中重新读取数据到处理器缓存里。
这也就是Volatile实现的原理。

重排序

刚才上面总结到了重排序的概念,那什么是重排序呢?

简单的理解就是,当程序在执行的时候,如果JVM认为两行代码之间的结果互不影响,那么在执行的过程中可能就会产生一个乱序的结果。
例如:

a = 3; 
 b = 4;

正常情况下我们会觉得a = 3肯定是比b=4先执行的,因为它在b的上面,但是实际上并不是这样,因为b的运行结果并不依赖上一行a的结果,因此JVM就能够对两行代码进行一个重排序,可能a先执行,也可能b先执行

为什么要采用重排序?
重排序通常是编译器或运行时环境为了优化程序性能而采取的。它可以分为两类:编译器重排序和运行时重排序。

顺序一致性模型:理想的模型是,各种指令执行的顺序是唯一且有序的,这个顺序就是它们被编写在代码中的顺序,与处理器或其他因素无关

顺序一致性模型的缺点:效率过低

编译器重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下,尽可能的减少寄存器的读取、存储次数、充分服用寄存器的存储值。

假设第一条指令计算一个值赋给变量A并存放在寄存器中,第二条指令与A无关但需要占用寄存器(假设它将占用A所在的那个寄存器),第三条指令使用A的值且与第二条指令无关。那么如果按照顺序一致性模型,A在第一条指令执行过后被放入寄存器,在第二条指令执行时A不再存在,第三条指令执行时A重新被读入寄存器,而这个过程中,A的值没有发生变化。通常编译器都会交换第二和第三条指令的位置,这样第一条指令结束时A存在于寄存器中,接下来可以直接从寄存器中读取A的值,降低了重复读取的开销。

并发时的乱序问题

上面总结的重排序可以引起乱序,同样的,在并发时对局部变量进行操作也有可能会产生乱序的问题。因为在每个线程中都拥有一个独立的栈,也就是独立的线程空间,当它运行时,会从主内存中读取该变量的值并存放到自己的线程栈中,对变量操作完成后就会把值写回主内存空间。
但是这里就有一个问题了,那就是变量的写回操作发生的时间并不能够确定。就算是线程A比线程B先读取数据,仍然有可能线程B先把值写回主内存,最终同样会造成一个得到的结果并不是我们想要的值。

Happens-before(先行发生)

Java内存模型(JMM)为程序中所有的操作定义了一个偏序关系,称之为Happens-before。如果想要保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作时间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。

当一个变量被多个线程读取并且至少被一个线程写入时,如果在读写操作之间没有按照Happens-Before来排序,那么就会产生数据竞争的问题。

Happens-Before规则包括:

  • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行
  • 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行
  • volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行
  • 线程启动规则:在线程上对Thread.Start的调用必须在该县城中执行任何操作之前执行
  • 线程结束规则:县城中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者在Thread.join中成功返回,或者在调用THread.isAlive时返回false
  • 中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行
  • 终结器规则:对象的构造函数必须在启动终结器之前执行
  • 传递性:操作A在B之前,B在C之前,那么A就必须在C之前执行

例如线程A:y=1 -》 lock M -》 x=1 -》unlock M
线程B: lock M -》 i=x -》 unlock M -》 j=y

当两个线程使用同一个锁进行同步时,在它们之间的happens-Before关系就是:A的unlock M执行完成之后才能执行B的lock M方法,如果这两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为在这两个线程的操作之间并不存在Happens-Before关系

Lock / Synchronized / ReentrantLock(独占锁 / 悲观锁)

Synchronized
内置锁,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码,另一个线程必须等待当前线程执行完这个代码快以后才能执行该代码块(未执行之前,该线程被阻塞)。同时,它也是一个可重入锁(Lock均可重入)

但是这里有一个很关键的地方:当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块。

之前在单例模式中总结到了双重检查锁定模式,但是由于双重检查锁定模式在一定情况下存在很严重的Bug,就没有在该博客中写出。这里就对双重检查锁定模式进行一个分析

public static Singleton getInstance() {  
        if (singleton == null) {    
            synchronized (Singleton.class) {    
               if (singleton == null) {    
                  singleton = new Singleton();   
               }    
            }    
        }    
        return singleton;   
    }

因为在new Singleton()的过程中,实际上是可以分为很多步的,可大致分为三件事情:

  • 给Singleton的实例分配内存
  • 调用Singleton的构造函数,初始化字段
  • 将singleton对象指向分配的内存空间(singleton != null)

但是,Java编译器是允许处理器乱序执行的,所有有可能让第二步和第三步乱序执行,也就是说如果在第二步被乱序(安排到了最后一步执行),当他还没执行的时候切换到了线程B,这个时候就会因为singleton已经不为null而直接跳出if判断,这样的话在我们以后的代码运行过程中使用的就是一个未经构造函数初始化的一个对象。【现在好像有在JDK层面有改进因此可以正常使用,具体的不清楚,以后再修改】

Lock

Lock是一个接口,它里面主要包含了下面的几个方法:
P.S.源码里的注释太多,这里就不贴了

void lock();
void lockInterruptibly();
boolean tryLock();
boolean tryLock(long time, TimeUnit unit);
void unlock();
Condition newCondition();

Lock它与内置加锁机制不同,它提供的是一种无条件的、可轮训的、定时的以及可中断的锁获取操作,所有的加锁和解锁的方法都是显式的。

一句话总结:Synchronized算是Lock的简化版本,功能比之较少但是在程序执行完成后会自动释放锁,而Lock必须手动释放锁

ReentrantLock
ReentrantLock它实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。
那为什么还要提供一种机制跟内置锁十分相似的新加锁机制呢?
因为内置锁在一定情况下存在局限性,例如无法中断一个正在等待获取锁的进程,或者无法在请求获取一个锁时无限地等待下去,;无法实现非阻塞结构的加锁规则。

而在ReentrantLock中,它可以实现轮询锁、定时锁、中断锁等多种加锁方式,这也让它的应用场景变的更多。

同时在性能上:如果有越多的资源被耗费在锁的管理和调度上,那么应用程序得到的资源就越少。锁的实现方式越好,就需要越少的系统调用和上下文切换,并且在共享内存总线上的内存同步通信量也越少。
在Java5中,ReentrantLock的性能比内置锁高了很多,但是在Java6中内置锁采取了一种类似与ReentrantLock中使用的算法来管理内置锁,有效地提高了可伸缩性,因此在Java6中,它们的吞吐量就非常接近了。

在公平性上,ReentrantLock可以创建一个非公平锁(默认)也可以创建一个公平锁。
公平锁:线程按照发出请求的顺序来获得锁(先到先得,不准插队)
非公平锁:当一个线程请求非公平锁时,如果在发出请求的同时该锁的状态变为可用,那么就跳过队列中所有的等待队列立刻获得锁(也就是允许插队。申请的时候锁为可用状态就直接获取)

而对于公平锁和非公平锁来说,它们的效率也是显而易见的:
公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低效率。
而非公平性由于是在请求时锁已经为可用状态就直接获取,不需要进行什么额外的操作,因此效率更高。
实际上:确保被阻塞的线程能最终获得锁就已经够用了,并且实际开销也小很多。

当在一个激烈竞争的情况下,恢复一个被挂起的线程与这个线程真正开始运行之间存在着严重的延迟,这样的话就影响了效率。而如果我们采用非公平锁(也就是ReentrantLock的默认方式),线程A释放锁时,B被唤醒然后尝试获取锁,与此同时C也请求这个锁,那么C很有可能会在B被完全唤醒之前获得、使用以及释放这个锁。也就有可能会造成B获得锁的时刻并没有推迟,C也更早的获得了锁

那什么时候应该使用公平锁呢?
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么就应该使用公平锁。

在Synchronize和ReentrantLock中如何选择
上面总结了这么多,好像ReentrantLock的优点比Synchronize好太多,那为什么不直接取消掉Synchronize呢?我们自己该怎么选择呢?

Synchronize很重要的几个优点就是:

  • 无需手动释放锁,程序自动完成。如果在使用Lock的过程中忘记在finally中释放锁,那么虽然代码表面上能正式运行,但是实际上已经出了大问题,还有可能伤到其他代码。所以一般仅仅是在做一些内置锁不能完成的需求时才考虑使用ReentrantLock,例如中断锁、轮询锁等等
  • 调试的问题:Synchronized在线程存储中能够给出在哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的进程。而ReentrantLock它只是一个对象,JVM不知道哪些线程持有这个。

非阻塞同步机制(乐观锁)

加锁机制始终会存在一个挂起唤醒的操作,如果有多个线程同时请求锁,那么JVM就需要借助操作系统的功能,而在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。如果在竞争激烈的时候,调度开销与工作开销的比值会非常高。

此外,如果一个线程正在等待锁时,它不能做任何其他事情,同时如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么问题更严重,也就是发生了优先级反转。即:高优先级的线程必须等到低优先级的线程释放锁,从而导致它的优先级会降低至低优先级线程的级别。

而最近的很多并发算法研究都侧重于非阻塞同步的机制,例如:Lock-free算法

Lock-free算法(无锁)

这个算法中主要使用到了一个CAS机制(Compare and swap),它包含了3个值,需要读写的内存位置V,需要进行比较的值A, 要写入的新值B。
它的原理就是:

当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不执行任何操作

而当多个线程常识使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他的线程都将失败。但是失败的线程并不会被挂起,而是被告知在这次竞争中失败,并可以尝试再次尝试。由于一个线程在竞争CAS时失败不会阻塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,再或者不执行任何操作,这种灵活性就大大减少了与锁相关的活跃性风险。

结语

并发的水实在太深,不花精力实在难以hold住,这里简单记录一下Java并发的知识点