下文为JAVA并发的学习笔记,部分学习自《Java核心技术 卷I》并发章节
一、什么是线程
进程是系统分配资源的最小单位,线程是系统调度的最小单位。一个进程内的线程之间是可以共享资源的。每个进程至少有一个线程存在,即主线程。
多线程程序在更低一层 中扩展了多任务的概念:单个程序看起来在同时完成多个任务。每个任务在一个线程(Thread)中执行。线程是控制线程的简称。如果一个线程可以同时运行多个线程,就称这个程序是多线程的。
1.1创建线程
方法1-继承Thread类
可以通过继承Thread来创建一个线程类,该方法的好处是this代表的就是当前线程,不需要通过Thread.currentThread()来获取当前线程的引用。
class MyThread extends Thread{
@Override
public void run(){
task code; // 任务代码
}
}
Mythread t = new MyThread();
t.start(); // 线程进入可运行状态
方法2-实现Runnable接口
通过实现Runnable接口,并且调用Thread构造方法时将Runnable对象作为target参数传入来创建线程对象。该方法的好处是可以规避类的单继承的限制,但需要通过Thread.currentThread()来获取当前线程的引用。
class MyRunnable implements Runnable{
@Override
public void run(){
task code; // 任务代码
}
}
Thread t = new Thread(new Runnable);
t.start();
1.2Thread类及常见方法
Thread类是JVM管理线程的一个类,换句话说,每个线程都有一个唯一的Thread对象与之关联。
每个执行流,需要一个对象来描述,而Thread类的对象就是用来描述一个线程执行流的,JVM会将Thread对象组织起来,用于线程调度,线程管理。
1.2.1Thread的常见构造方法
方法 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnable对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target,String name) | 使用Runnable对象创建线程对象,并命名 |
1.2.2Thread的几个常见属性
属性 | 获取方法 |
---|---|
ID | getID() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
- ID是线程的唯一标识,不同线程不会重复
- 名称是各种调试工具用到
- 状态表示线程当前所处的一个情况
- 优先级高得线程理论上来说更容易被调度上。
1.2.3启动一个线程-start()
警告:不要调用Thread类或Runnable对象的run方法。直接调用run方法只会在同一个线程中执行这个任务——而没有启动线程。实际上应当调用Thread.start方法,这会创建一个执行run方法的新线程。
1.2.4中断一个线程
目前常见的有以下两种方式:
1.通过共享的标记来进行沟通
2.调用Interrupt()方法来通知
1.thread对象调用interrupt()方法通知该线程停止运行。
2.thread收到通知的方式有两种:
1.如果线程调用了wait/join/sleep等方法而阻塞挂起,则以InterruptedException异常的形式通知,清除中断标志。
2.否则只是内部的一个中断标志被设置,thread可以通过
(1)Thread.interrupted()判断当前线程的中断标志被设置,清除中断标志
(2)Thread.currentThread().isInterrupted()判断指定线程的中断标志被设置,不清除中断标识。
方法 | 说明 |
---|---|
public void interrupt() | 中断对象关联的线程,如果线程正在阻塞,则以异常的方式通知,负责设置标志位 |
public static boolean interrupted() | 判断当前线程的中断标志位是否设置,调用后清除标志位 |
public boolean isInterrupted() | 判断对象关联的线程的标志位是否设置,调用后不清除标志位 |
1.2.5等待一个线程-jion()
有时候,我们需要一个线程完成它的工作后,才能进行自己的下一步工作。
方法 | 说明 |
---|---|
public void join() | 等待线程结束 |
public void join(long millis) | 等待线程结束,最多等millis毫秒 |
public void join(long millis,int nanos) | 同理,但可以更高精度 |
1.2.6休眠当前线程
因为线程的调度是不可控的,所以,这个方法只能保证休眠时间是大于等于休眠时间的。
方法 | 说明 |
---|---|
public static void sleep(long millis) throws InterruptedException | 休眠当前线程的millis毫秒 |
public static void sleep(long millis,int nanos) throws InterruptedException | 可以更高精度的休眠 |
二、线程状态
线程可以有如下六种状态:
- New(新建)
- Runnable(可运行)
- Blocked(阻塞)
- Waiting(等待)
- Timed Waiting(计时等待)
- Terminated(终止)
2.1新建线程
当用new操作符创建一个线程时候,这个线程还没有开始运行。这意味着它的状态是新建(new)。当一个线程处于新建状态时,程序还没有开始运行线程中的代码。在线程开始之前还有一些基础工作需要做。
2.2可运行状态
一旦调用了start方法,线程就处于可运行状态。一个可运行的线程可能正在运行也可能没有运行。要由操作系统为线程提供具体的运行时间。正是因为这样,这个状态叫做“可运行”而不是“运行”。
2.3阻塞和等待线程
当线程处于阻塞和等待状态时,它暂时是不活动的。它不运行任何代码,而且消耗最少的资源。要由线程调度器重新激活这个线程。具体细节取决于他是怎么到达非活动状态的。
- 当一个线程试图获取一个内部的对象锁,而这个线程目前被其它线程占有,该线程就会被阻塞。当所有其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将变成非阻塞状态。
- 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入等待状态。实际上,阻塞状态与等待状态并没有太大区别。
- 有几个方法有超时参数,调用这些方法会让线程进入计时等待状态。
2.4终止线程
线程会由于以下两个原因之一而终止:
- run方法正常退出,线程自然终止。
- 因为一个没有捕获的异常终止了run方法,使线程意外终止。
三、线程属性
3.1中断线程
当线程的run方法执行方法体最后一句后再执行return语句返回时,或者出现了方法中没有捕获的异常时,线程将终止。
除了已经废除的stop方法,没有办法可以强制线程终止。不过,interrupt方法可以用来请求终止一个线程。
当对一个线程调用interrupt方法时,就会设置线程的中断状态。这是每个线程都有的boolean标志。每个线程都应该不时地检查这个表示,以判断线程是否被中断。
要想得出是否设置了中断状态,首先调用静态地Thread.currentThread方法获得当前线程,然后调用isInterrupted方法。
但是,如果线程被阻塞,就无法检查中断状态。这里就要引入InterruptedException异常。当在一个被sleep或wait调用interrupt方法时,那个阻塞调用将被一个InterrupedException中断。
没有任何语言要求被中断的线程应当终止。中断一个线程只是要引起它的注意。被中断的线程可以决定如何响应中断。某些线程非常重要,所以应当处理这个异常。然后再继续执行。但是更普遍的情况是,线程只希望中断解释为一个终止请求。
注意区分:interrupt,interupted,isInteerrupted
3.2守护线程
守护线程的唯一用途是为其他线程提供服务。可以通过
t.setDaemon(true);
将一个线程转换为守护线程。
当只剩下守护线程时候,虚拟机就会退出。因为如果只剩下守护线程。就没有必要继续运行程序了。
3.3未捕获异常的处理器
四、同步
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取同一个对象,并且每个线程分别调用一个修改该对象状态的方法,会发生什么?可以相见,这两个线程会相互覆盖。取决于线程访问数据的次序,可能会导致对象被破坏。这种情况称为竞态条件(race condition)。经常被用来做例子的就是抢票和银行转账了,有兴趣自己查下。
在银行转账的例子中,当两个线程试图同时更新一个账户时,就会出现这个问题。假设两个线程同时执行如下指令。
accounts[to] += amount;
问题在于这不是原子操作。这个指令可能如下处理:
1.将accounts[to]加载到寄存器上
2.增加account。
3.将结果写回accounts[to]
由于线程可能在任何一条指令上被中断,由此引出多线程带来的风险-线程安全。
如果多线程环境下运行的结果是符合我们预期的,即在单线程环境应该的结果,就说这个线程是安全的。
线程不安全的因素可以归结为三个:
4.1多线程不安全的原因
4.1.1原子性
前面已经解释过什么叫原子性。
一条java语句不一定是原子的,也不一定只是一条指令
我们看到的n++,其实是由三步操作组成的:
1.从内存把数据读到CPU
2.进行数据更新
3.将数据写会CPU
不保证原子性会给多线程带来什么问题:
如果一个线程正在对一个变量操作,中途其他线程插入进来,如果这个操作被打断了,结果就可能是错误的。
4.1.2可见性
为了提高效率,JVM在执行过程中,会尽可能的将数据在工作内存中执行,但这样会造成一个问题,共享变量在多线程之间不能及时看到改变,这个就是可见性问题。
4.1.3代码顺序性(指令重排序)
Java语言规范JVM线程内部维持顺序化语义,即只要程序的最终结果与它的顺序化情况结果相等,那么指令的执行顺序可以与逻辑顺序不一致.这个过程就叫做指令重排序.
指令重排序的意义:使指令更加符合CPU的执行特性,最大限度发挥机器的性能,提高程序的执行效率.
源代码到最终执行的指令序列示意图:
指令重排序主要分三种:
1.编译器重排序:JVM中进行完成的
2.指令级并行重排序
3.处理器重排序
指令重排序会给多线程带来什么问题
还是刚才转账的那个例子中,单线程是没问题的,优化是正确的,但在多线程场景下就有问题了,什么问题呢?
如果将123的顺序改变132就会产生错误,这是显而易见的.
4.2锁对象
有两种机制可以防止并发访问代码块.Java语言提供了一个Synchronized关键字来达到这一目的,另外java5引入了ReetrantLock类.Synchronized关键字提供了一个锁以及相关的"条件",对于大多数需要显式锁的情况,这种机制功能很强大.
4.3条件对象
4.5synchronized关键字
在开始之前,先对锁和条件来做一个总结:
- 锁是用来保护代码片段,一次只能有一个线程执行被保护的线程
- 锁可以管理试图进入被保护代码段的程序
- 一个锁可以有一个或多个相关联的条件对象
- 每个条件对象管理哪些已经进入被保护代码段但还不能运行的线程.
从1.0版本开始,Java中的每个对象都有一个内部锁.如果一个方法声明时有synchronized关键字,那么对象的锁将保护整个方法.也就是说,要调用这个方法,线程必须获得内部对象锁.
内部锁和条件存在一些限制.包括:
- 不能中断一个正在尝试获得锁的线程.
- 不能指定尝试获得锁时的超时时间.
- 每个锁仅有一个锁是不够的
在代码中应该使用哪一种做法?Lock和Condition对象还是同步方法?下面是一些建议:
- 最好既不使用Lock/Condition也不使用synchronized关键字.在许多情况下.可以使用Java.util.concurrent包中的某种机制 .它会为你处理所有锁定
- 如果synchronized关键字适合你的程序,那么尽量使用这种方法,这样可以减少编写的代码量,还能减少出错的概率.
- 如果特别需要Lock/Condition结构提供的额为能力,则使用Lock/Condition
总结下synchronized关键字的作用:
- 当线程释放锁时,JMM会把该线程对应的工作内存种的共享变量刷星到主内存种
- 当线程获取锁时,JMM会把线程对应的本地内存置为无效.从而使得被监视保护的临界区代码必须从主内存种读取共享变量.
4.6同步块
Java对象都有一个锁.线程可以通过调用同步方法获得锁.还有另一种机制获得锁:即进入一个同步块.
在这里,创建lock对象只是为了使用每个Java对象拥有的锁.
有时程序员使用一个对象的锁来实现额为的原子操作,这种做法称为客户端锁定.客户端锁定是非常脆弱的,通常不推荐使用.
4.7监视器概念
4.8volatile字段
有时,如果只是为了读写一两个实例字段而使用同步,所带来的开销好像有些划不来.毕竟,什么地方能出错呢?遗憾的是,使用现代的处理器与编译器,出错的可能性很大.
如果你使用锁来保护可能被多个线程访问的代码,那么不存在这个问题.编译器被要求在必要的时候刷新本地缓存来支持锁,而且不能不相应的重新相应的重新排列指令顺序.
volatile关键字为实例字段的同步访问提供了一种免锁机制.如果声明一个字段为volatile,那么编译器和虚拟机就知道该字段可能被另一个线程并发更新.保证了可见性,但volatile变量不能提供原子性.
4.9final变量
还有一种情况可以安全的访问一个共享字段,即这个字段声明为final.考虑以下声明:
final varaccounts = new HashMap<String,Double>();
其他线程会在构造器完成构造之后才看到这个accounts的变量.
五、线程安全的集合
六、任务和线程池
构造一个新的线程开销有些大,因为这涉及与操作系统的交互.如果你的程序中创建了大量的生命期很短的线程,那么不应该把每个任务映射到一个单独的线程,而应该使用线程池(Thread pool).线程池中包含了许多准备运行的线程.为线程池提供一个Runnable,就会有一个线程调用run方法.当run方法退出时,这个线程不会死亡,而是留在池中准备为下一个请求提供服务.\
6.1Callable与Future
6.2执行器
执行器类(Executors)有许多静态工厂,用来构造线程池,下表对这些方法进行了汇总:
方法 | 描述 |
---|---|
newCachedThreadPool | 必要时创建新线程;空闲线程会保留60秒 |
newFixedThreadPool | 池中包含固定数目的线程;空闲线程会一直保留 |
newWorkStelingPool | 一种适合"fork-join"任务的线程池,其中复杂的任务会分解为更简单的任务,空闲线程池会"密取"较简单的任务 |
newSingleThreadExecutor | 只有一个线程的池,会顺序执行所提交的任务 |
newScheduledThreadPool | 用于调度执行的固定线程池 |
newSingleThreadScheduledExector | 用于调度执行的单线程"池" |
6.3控制任务组
6.4fork-join框架
一些应用可能对每个处理器内核分别使用一个线程,以完成计算密集型任务,如图像或视频处理.
在后台,fork-join框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为工作密取(work stealing).每个工作线程都有一个双端队列来完成任务.一个工作线程将子任务压入其双端队列的队头.一个工作线程空闲时,它会从另一个双端队列的队尾"密取"一个任务.由于大的任务都在队尾,这种密取很少出现.