并发

多线程编程有时也被称为并发编程,但其实多线程编程只是并发编程的一种实现方式,并发编程还有其他的实现途径,如:函数式编程,单多线程编程往往是并发编程的基础模型。

区分 “并行”

  1. 并行是指两个或多个线程在 同一时刻 执行
  2. 并发是指两个或多个线程在 同一时间间隔内 发生。 如果程序同时开启的线程数小于CPU的核数,那么不同进程的线程就可以分配给不同的CPU来运行,这就是并行;如果线程数多于CPU的核数,那就需要并发技术。通俗一点可以这样理解,并行表示两个线程同时做事情;并发表示一会做这个,一会做那个,存在着调度(单核 CPU 不可能存在并行)

线程同步

  1. 同步:一个任务执行完成后才能执行下一个任务,单线程只能执行一个任务。
  2. 线程同步:线程同步不是说让一个线程执行完了再执行其它线程,一般是指让线程中的某一些操作进行同步,从而避免多线程访问同一数据时产生线程安全问题

线程和线程之间内存共享,当多线程对共享内存进行操作时,难免会出现 竞态条件内存可见性 的问题

竞态条件

Java 允许多线程并发控制,但当多线程同时操作一个可共享的资源变量时,将会导致数据的不准确,相互之间产生冲突,此时,应该避免当前线程没有完成操作之前资源被其他线程调用,保证该变量的准确性和唯一性。常见的解决方法有:

  • 使用 synchronized 关键字
  • 使用显式锁(Lock)
  • 使用原子变量(java.util.concurrent.atomic)

内存可见性

内存是一个硬件,执行速度比CPU慢几百倍,所以在计算机中,CPU在执行运算的时候,不会每次运算都和内存进行数据交互,而是先把一些数据写入CPU中的缓存区(寄存器和各级缓存),在结束之后写入内存。这个过程是很快的,单线程下并没有任何问题。但是在多线程下就出现了问题,一个线程对内存中的一个数据做出了修改,但是并没有及时写入内存(暂时存放在缓存中);这时候另一个线程对同样的数据进行修改的时候拿到的就是内存中还没有被修改的数据,也就是说一个线程对一个共享变量的修改,另一个线程不能马上看到,甚至永远看不到。常见的解决方法有:

  • 使用 volatile 关键字
  • 使用 synchronized 关键字
  • 使用显式锁(Lock)同步

传统锁 synchronized

一般说的 synchronized 用来做多线程同步功能,其实 synchronized 只是提供多线程互斥,而对象的 wait() 和 notify() 方法才提供线程的同步功能。

一般说 synchronized 是加锁,或者说是加对象锁。其实对象锁只是 synchronized 在实现锁机制中的一种锁(重量锁,用这种方式互斥线程开销大所以叫重量锁,或者叫对象 monitor ),而 synchronized 的锁机制会根据线程竞争情况在运行会有偏向锁、轻量锁、对象锁,自旋锁(或自适应自旋锁)等。

总之,synchronized 可以认为是一个几种锁过程的封装。

同步代码块

每个 java 对象都有一个互斥锁标记,用来分配给线程。使用 synchronized 关键字修饰语句块,该语句块会自动被加上内置锁,从而实现同步,只有拿到锁标记的线程才能够进入对 object 加锁的同步代码块

# 同步是一种高开销的操作,因此应该尽量减少同步的内容
# 通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可
synchronized(object){

}

当有一个明确的对象作为锁时:

public void method3(SomeObject obj) {
   //obj 锁定的对象
   synchronized(obj)
   {
      // todo
   }
}

当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁:

private byte[] lock = new byte[0];  // 特殊的instance变量
public void method() {
   synchronized(lock) {
      // todo 同步代码块
   }
}

说明:零长度的byte数组对象创建起来将比任何对象都经济――查看编译后的字节码:生成零长度的byte[]对象只需3条操作码,而Object lock = new Object()则需要7行操作码。

同步方法

  • 同步对象方法

synchronized 作为方法修饰符修饰的方法被称为同步方法,表示对 this 加锁的同步代码块(整个方法都是一个代码块)。

public synchronized void save(int i) {
    i++;
}
  • 同步静态方法

静态方法是属于类的而不属于对象,synchronized 修饰的静态方法锁定的是这个类的所有对象。

同步类

synchronized 还可以作用于一个类:

class ClassName {
   public void method() {
      synchronized(ClassName.class) {
         // todo
      }
   }
}

synchronized 作用于一个类T时,是给这个类加锁,该类的所有对象用的是同一把锁。

synchronized 总结

  • 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁
  • 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码
  • 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制

注意:

  1. synchronized 关键字不能继承
  2. 构造方法不能使用 synchronized 关键字,但可以使用 synchronized 代码块来进行同步
  3. 在定义接口方法时不能使用 synchronized 关键字
  4. 锁的公平与不公平:公平锁是指线程获得锁的顺序按照 FIFO 的原则,先排队的先得。非公平锁指每个线程都先要竞争锁,不管排队先后,所以后到的线程有可能无需进入等待队列直接竞争到锁。非公平锁虽然可能导致某些线程饥饿,但是锁的吞吐率是公平锁好几倍,synchronized 是一个典型的非公平锁方案,而且没法做成公平锁。

显式锁(Lock)

ReentrantLock

ReentrantLock 具有和 synchronized 相似的作用,但是更加的灵活和强大。它是一个重入锁(synchronized 也是)。

所谓重入就是可以重复进入同一个函数,这有什么用呢?

假设一种场景,一个递归函数,如果一个函数的锁只允许进入一次,那么线程在需要递归调用函数的时候,应该怎么办?退无可退,有不能重复进入加锁的函数,也就形成了一种新的死锁。 重入锁的出现就解决了这个问题,实现重入的方法也很简单,就是给锁添加一个计数器,一个线程拿到锁之后,每次拿锁都会计数器加1,每次释放减1,如果等于0那么就是真正的释放了锁。

//创建一个锁对象
Lock lock = new ReentrantLock();

//上锁(进入同步代码块)
lock.lock();

//解锁(出同步代码块)
lock.unlock();

//尝试拿到锁,如果有锁就拿到,没有拿到不会阻塞,返回false
tryLock();

synchronized 和 ReentrantLock 的区别

  1. 两者都是互斥锁,所谓互斥锁:同一时间只有一个拿到锁的线程才能够去访问加锁的共享资源,其他的线程只能阻塞
  2. 都是重入锁,用计数器实现
  3. ReentrantLock 独有特点
    • ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁
    • ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程
    • ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制

ReadWriteLock

读写锁,读写分离。分为 readLock 和 writeLock 两把锁。对于 readLock 来说,是一把共享锁,可以多次分配;但是当 readLock 锁上的时候,调用 writeLock 是会阻塞的,反之亦然。另外,writeLock 是一把普通的互斥锁,只可以分配一次。

ReadWriteLock: 允许读操作并发执行;不允许“读/写”, “写/写”并发执行。

当数据结构需要频繁的读时,ReadWriteLock 相比 ReentrantLock 与 synchronized 的性能更好。

volatile 关键字

volatile 修饰符用来保证可见性。使用 volatile 修饰的变量是一种特殊域变量。

当一个共享变量被 volatile 修饰的时候,他会保证变量被修改之后立马在内存中更新,另一线程在取值的时候需要去内存中读取新的值。

注意:尽管 volatile 可以保证变量的内存可见性,但是不能够保存原子性,对于b++这个操作来说,并不是一步到位的,而是分为好几步的,读取变量,定义常量1,变量b加1,结果同步到内存。虽然在每一步中获取的都是变量的最新值,但是没有保证b++的原子性,自然无法做到线程安全

  1. volatile 关键字为域变量的访问提供了一种免锁机制,
  2. 使用 volatile 修饰域相当于告诉虚拟机该域可能会被其他线程更新,
  3. 因此每次使用该域就要重新计算,而不是使用寄存器中的值
  4. volatile 不会提供任何原子操作,它也不能用来修饰 final 类型的变量

线程 Thread

Thread 类中 start() 和 run()

start() 用来启动一个线程,当调用 start 方法后,系统才会开启一个新的线程,进而调用 run() 方法来执行任务,而单独的调用run() 就跟调用普通方法是一样的,已经失去线程的特性了。因此在启动一个线程的时候一定要使用 start() 而不是 run()。

Thread 类中 sleep() 和 wait()

sleep() 方法是线程类(Thread)的静态方法,作用是使调用线程暂停执行一段指定时间,将执行机会给其他线程。但其监视状态依然保持,暂停时间结束后,会回复到就绪状态,所以调用 sleep() 不会释放对象锁。

wait() 是 Object 类的方法,对此对象调用 wait() 方法导致本线程放弃对象锁(线程暂停执行),进入等待此对象的等待锁定池,只有针对此对象发出 notify(或 notifyAll)后本线程才进入对象锁定池准备获得对象锁,从而进入就绪状态。

Thread 类中 sleep() 和 yield()

  • sleep() 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield() 方法只会给相同优先级或更高优先级的线程以运行的机会;
  • 线程执行 sleep() 方法后转入阻塞(blocked)状态,而执行 yield() 方法后转入就绪(ready)状态;
  • sleep() 方法声明抛出 InterruptedException,而 yield() 方法没有声明任何异常;
  • sleep() 方法比 yield() 方法(跟操作系统相关)具有更好的可移植性。

线程的状态

  • 就绪(Runnable):线程准备运行,不一定立马就能开始执行。
  • 运行中(Running):进程正在执行线程的代码。
  • 等待中(Waiting):线程处于阻塞的状态,等待外部的处理结束。
  • 睡眠中(Sleeping):线程被强制睡眠。
  • I/O 阻塞(Blocked on I/O):等待 I/O 操作完成。
  • 同步阻塞(Blocked on Synchronization):等待获取锁。
  • 死亡(Dead):线程完成了执行。

参考资料