Fork me on GitHub

Java并发编程的艺术(3)

Java并发编程基础

线程:现在操作系统调度的最小单元,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。

线程的状态

状态名称 说明
NEW 初始状态,线程被构建,但是还没有调用start()方法
RUNNABLE 运行状态,Java线程将操作系统中的就绪和运行状态合并称为”运行中”
BLOCKED 阻塞状态,表示线程阻塞于锁
WAITING 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING 超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的
TERMINATED 终止状态,表示当前线程已经执行完毕

Java线程状态变迁

上图中sleep->ready to run 有错误,只能sleep结束或者通过调用 Thread.interrupt()

Daemon线程:一种支持型线程,主要被用作程序中后台调度以及支持性工作。这就意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。

启动线程

线程对象在初始化完成之后,调用start()方法就可以启动这个线程。

中断

中断可被理解为一个标志位属性,它表示为一个运行中的线程是否被其他线程进行了中断操作。

不再推荐的suspend()、resume()和stop()

不再推荐使用的原因是suspend()线程不会释放已经占有的资源,而是占有者资源进入睡眠状态,这样容易引起死锁问题,stop()不能保证资源正常释放,容易处于不确定状态下

线程间通信

volatile和synchronized关键字

volatile: 可以用来修饰字段,就是告知程序任何对该对变量的访问要从共享内存中获取,而对它的改变必须同步刷新回共享内存中,它能保证所有线程对变量访问的可见性。
synchronized:可以修饰方法或以同步块的形式进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。

等待/通知机制

等待/通知机制: 一个线程A调用对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。

方法名称 描述
notify() 通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁
notifyAll() 通知所有等待在该对象上的线程
wait() 调用该方法的线程进入WAITING状态,只有等待另外线程的通知或被中断才会返回,需要注意,调用wait()方法后,回释放对象的锁
wait(long)
wait(long,int)

调用wait()、notify()和notifyAll()注意的细节

  1. 使用wait()、notify()和notifyAll()时需要先对调用的对象加锁
  2. 调用wait()方法后,线程状态有RUNNING变为WAITING,并将当前线程放置到对象的等待队列
  3. notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或notifyAll()的线程释放锁之后,等待线程才有机会从wait()返回
  4. notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中的所有线程全部移到同步队列,被移动的线程从WAITING变为BLOCKED
  5. 从wait()方法返回的前提是获得了调用对象的锁

等待方遵循如下原则:

  1. 获取对象的锁
  2. 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件
  3. 条件满足则执行对应的逻辑

通知方遵循的原则:

  1. 获得对象的锁
  2. 改变条件
  3. 通知所有等待在对象上的线程

PipedOutputStream,PipedInputStream,PipedReaderPipedWriter前两种面向字节,后两种面向字符

Thread.join()

Thread.join()含义:当前线程A等待thread线程终止之后才从Thread.join()返回。

ThreadLocal

ThreadLocal,即线程变量,是一个以ThreadLocal对象为键,任意对象为值的存储结构,这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

Java中的锁

Lock接口

一个锁能够防止多个线程同时访问共享资源。在Lock出现之前,是采用synchronized关键字来实现锁功能的。那现在为什么取代了?

Lock接口提供的synchronized关键字不具备的主要特性

特性 描述
尝试非阻塞地获取锁 当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
能被中断地获取锁 与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
超时获取锁 在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回

队列同步器

队列同步器AbstractQueueSynchronizer,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。

队列同步器是实现锁的关键,锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;队列同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。

队列同步器的实现分析

同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态

同步队列

队列同步器依赖内部的同步队列来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

当一个线程成功地获取了同步状态(或者锁),其他线程将无法同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,所以同步器提供了一个基于CAS的设置为节点的方法。

需要传递当前线程”认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。

设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法不需要使用CAS来保证,只需要将首节点设置为原首节点的后继节点并断开原首节点的next引用即可。

独占式同步状态获取与释放

通过同步器的acquire(int arg)方法获取同步状态。

1
2
3
4
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

主要逻辑:

  1. 调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态
  2. 如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态),并通过addWaiter(Node node)方法将该节点加入同步队列的尾部
  3. 最后调用acquireQueued(Node node,int arg)方法,是的该节点以”死循环”的方式获取同步状态。
  4. 如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现

但是这个方法对中断不敏感,也就是说由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出。

独占式同步状态获取流程

  1. 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入队列中并在对队列中进行自旋
  2. 移出队列(或停止自旋)的条件是前驱节点为头结点且成功获取了同步状态
  3. 在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点

共享式同步状态获取与释放

与独占式主要区别是在于tryReleaseShared(int arg)方法必须确保同步状态(或资源数)线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。

独占式超时获取同步状态

重入锁

重入锁:支持重进入的锁,表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平选择。

实现重进入
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。这样就需要解决两个问题

  • 线程再次获取锁,锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取
  • 锁的最终释放,线程重复n次获取锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁得最终释放要求对于获取进行技术自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。

公平和非公平获取锁的区别
如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
公平锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平锁虽然可能造成线程”饥饿”,但极少的线程切换,保证了其更大的吞吐量。

读写锁

读写锁在同一时刻允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程都会被阻塞。

读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态。

假设当前同步状态值为S,写状态等于S&0x0000FFFF,读状态等于S>>>16,当写状态增加1时,等于S+1,当读状态增加1时,等于S+(1<<16)也就是S+0x00010000

S不等于0时,当写状态等于0,则度状态大于0,即读锁已被获取

Java并发容器和框架

ConcurrentHashMap

出现的原因

  • 线程不安全的HashMap

HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entery链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry

  • 效率低下的HashTable

当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进行阻塞和轮询状态。

  • ConcurrentHashMap优势
    ConcurrentHashMap使用锁分段技术,容器中有多把锁,每一把锁用于锁容器其中一部分数据,当多线程访问容器中不同数据段的数据时,线程间就不会存在锁竞争,从而有效提高并发访问效率。

ConcurrentHashMap的结构

ConcurrentHashMap由Segment数组结构和HashEntry数组结构组成。Segment是一种重入锁,在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据

Java中的线程池

线程池优点

  1. 降低资源消耗
  2. 提高响应速度
  3. 提高线程的可管理性

线程池的实现原理

当提交一个新任务到线程池中,线程池的处理流程

  1. 线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程
  2. 线程池判断工作队列是否已经满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  3. 线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务,如果已经满了,则交个饱和策略来处理这个任务。