并发艺术--java并发编程基础
前言
java语言内置对多线程的支持。那为什么使用多线程的?首先线程是操作系统最小的调度单元,多核心,多个线程可以同时执行,能够提升程序性能。但过多的线程也会导致问题(效率下降,死锁)。
一、线程简介
1.1 什么是线程
进程:现代操作系统运行一个程序时,会为其创建一个进程。(进程可认为是运行的程序,活的程序)。
线程:现代操作系统的最新调度单位就是线程,线程也称为轻量级进程(Light Weight Process)。
一个进程中可以包含多个线程,这些线程有自己的计数器、堆栈、和局部变量属性,并且能够访问共享的内存变量。
为什么线程能够同时执行:处理器在不同的线程间高速切换,让使用者感觉到线程在同时运行。
java程序天生就是一个多线程,下面展示一个简单的java程序包含哪些最基本的线程:
public class Main { public static void main(String[] args) throws Exception{ //获取java线程管理MXBean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); //不需要获取同步的Monitor和synchronizer信息,仅获取线程和线程堆栈信息 ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false,false); //遍历线程信息,仅打印线程Id和线程名称 for (ThreadInfo ti : threadInfos) { System.out.println("[" + ti.getThreadId() + "] " + ti.getThreadName()); } } }
//结果Signal Dispatcher 分发处理发送给JVM信号的线程。
Finalizer 调用对象的finalize方法的线程
Reference Handler 清除Reference的线程
main 主线程
可以看到一个普通的java程序不仅仅只有主线程,而且还有其他多个线程在运行。
1.2 为什么要使用多线程
正确使用多线程,能够给开发人员带来显著的好处。
使用多线程的原因主要以下几点:
1)更多处理器核心:
一个线程在同一时刻只能运行在一个处理器上,将逻辑分配到多个核心处理器上更加有效率。
2)更快的响应时间:
将数据一致性不强的操作分配给其他线程,使响应用户请求的线程尽快完成,缩短响应时间。
3)更好的编程模型
考虑问题时,仅需将业务建立起合适的模型,而无须考虑复杂的底层实现。
1.3 线程的优先级
现代操作系统采用时分的形式调度运行的线程。
操作系统分出一个个时间片,线程会分配到若干时间片,时间片使用完就会发生线程调度,等待下次分配。线程分配的时间片多少,也决定了线程使用处理器资源的多少。
线程优先级就是决定线程需要多或者少分配一些处理器的线程属性。
java线程中,通过整型变量priority来控制优先级,范围从1~10,超出就会报出异常。
创建线程时,默认优先级是5.
优先级高则分配到的时间片数量多于优先级低的。
策略:针对频繁阻塞(休眠/IO操作)的线程需要设置较高的优先级,针对计算的(占用较多CPU时间或者偏向运算)的设置较低的优先级,这样会避免线程被独占。
有些操作系统会忽略对线程优先级的设定,示例代码如下:
SO注意:线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会java线程对于优先级的设定。
1.4 线程的状态
java线程在运行的生命周期中有6种不同的状态。在给定的一个时刻,线程只能处于其中的一个状态。
事实上,线程在自身的生命周期中,并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换。如下图:
注:java将操作系统中的运行和就绪两个状态合并称为运行状态,阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(竞争锁)时的状态。
而在阻塞在java.concurrent包中Lock接口的线程状态确实等待状态,因为Lock接口对于阻塞的实现使用了LockSupport类中相关方法。
1.5 Daemon线程(守护线程)
Daemon线程是一种支持性线程,主要是用在后台程序做一些后台调度与支持性工作。这意味着当JVM没有Daemon线程时,JVM将自动退出。
可以通过调用Thread.setDaemon(true) 方法将线程设为Daemon线程。(注:该方法必须在start()或者run()方法前执行,也就是说必须在线程启动前执行)
Daemon线程被用作,完成支持性工作,但是在java虚拟机退出时,Daemon线程中的finally块并不一定执行。
注:在构建Daemon时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。
二、启动和终止线程
2.1 构造线程
运行线程之前,首先要创造一个线程对象,线程对象在构造的时候需提供线程所需要的属性,如线程组、优先级、是否为Daemon线程等信息。
下面是java.lang.Thread中对线程进行初始化的部分
从这里也能看到父线程与子线程的关系:
父线程就是当前线程(开启多线程的线程),子线程会具有与父线程一致的优先级, 守护线程,线程组,还会有父线程的可继承ThreadLocal。还会分配给一个唯一的ID。
init()运行完毕,线程对象就初始化好了,在堆内存中等待运行。
2.2 启动线程
线程完成初始化后,调用start()方法就可以启动这个线程,
线程start()的含义:当前线程同步告知JVM,只要线程规划器空闲,应立即启动调用start()方法的线程。
注意:作为一个习惯,最好为自定义线程起一个好名字。
2.3 理解中断
中断:一个标识位属性,通过调用线程的interrupt()方法使其进入中断状态。
线程可以通过检查自身是否被中断来进行响应。
线程通过方法isInterrupted()来判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断进行复位。
注:线程已经结束,即使线程曾经处于中断状态,调用线程对象的isInterrupted()依旧会返回false。
只要线程进入打断状态(调用interrupt()方法),在调用sleep(),会抛出异常InterruptedException。同时JVM会将线程的打断状态清空,此时再调用isInterrupted()会返回false。
2.4 过期的suspend()、resume()和stop()方法
suspend()用于暂停线程、resume()用于恢复线程、stop()用于停止线程,这三个方法都过期了。
原因:suspend()会导致线程占用资源进入休眠状态,容易导致死锁。stop()不能保证线程资源的正确释放,一旦调用直接结束,可能会导致程序运行在不确定的状态。
暂停恢复方法可以用后面的等待/通知机制完成。
2.5 安全地终止线程
除中断以外,还可以利用一个boolean变量来控制是否需要停止任务并终止该线程。
public class InterruptTest { public static void main(String[] args) throws Exception{ Runner one = new Runner(); Thread countThread = new Thread(one,"CountThread"); countThread.start(); //睡眠一秒,main线程对CountThread进行中断,使CountThread能够感知并结束。 Thread.sleep(1000); countThread.interrupt(); Runner two = new Runner(); countThread = new Thread(two,"CountThread"); countThread.start(); //睡眠一秒,main线程对two进行cancel,使得CountThread能够感知并结束 Thread.sleep(1000); two.cancel(); } private static class Runner implements Runnable { private long i; private volatile boolean on = true; @Override public void run() { while (on && !Thread.currentThread().isInterrupted()) { i++; } System.out.println("Count i = " + i); } public void cancel() { on = false; } } }
//结果如下
这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程停止,因此这种终止线程的做法显得更加安全和优雅。
三、线程间的通信
线程自启动时,就拥有了自己的栈空间。然后会一直运行直到结束。
多线程的目的是多条线程执行不同的逻辑业务从而能够提升业务整体的响应速度。
如果线程仅仅是孤零零的执行,这些不同的逻辑业务就不能最终汇聚成一个完整的业务那么多线程也就失去了意义,这就是为什么要有线程间通信的存在。
3.1 volatile与synchronized关键字
java支持多个线程访问一个对象的成员变量,在不使用关键字时,每一个线程是从自己的内存区域获取相应对象的拷贝的。
注:线程有自己的内存区域,默认会将共享内存中的数据拷贝到自己的内存区域,然后对拷贝值进行操作。这样可以加速程序的执行,这也是现代多处理器的一个特点。
但是这也会引出一个问题,即每一个线程获得的变量并不一定是最新值。
关键字volatile可以修饰字段(成员变量),就是告知程序,任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
注:过多地使用volatile是不必要的,因为它会降低程序执行的效率。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一时刻,只能有一个线程处于方法或者同步块中,它确保了线程对变量访问的可见性和排他性。
实质上是对一个对象的监视器(monitor)的获取,而且这个获取过程是排他的,也就是说同一时刻只有一个线程获取由synchronized所保护对象的监视器。
任何对象都有自己的监视器,当对象由同步块或者对象的同步方法调用时,执行方法的线程必须先获取对象的监视器才能进入同步块或者同步方法,而没有获取监视器的线程会阻塞在同步块与同步方法的入口,进入BLOCKED状态。
3.2 等待/通知机制
等待/通知的相关方法是任意java对象都具备的,因为该方法被定义在所有对象的超类上java.lang.Object。
等待通知机制:线程A调用了对象O的wait() 方法进入了等待状态,而线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。
注:上述两个线程通过对象O来完成交互,而对象的wait()与notify()或notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
使用注意:
1)使用wait()、notify()、notifyAll() 方法都需要先对调用对象加锁。(即锁对象应该为调用对象)
2)调用wait()方法后,线程状态由RUNNING变为WAITTING,将锁释放,并将当前线程放到对象的等待队列。
3)notify()或notifyAll() 方法调用后,不会立刻释放锁,需要等待调用notify()、notifyAll()的线程释放锁之后,等待线程才可能会拿到锁。
4)notify()将对象的等待队列中的一个线程随机地移到同步对象,notifyAll()将等待队列中的全部线程都移到同步队列,然后使它们争抢锁,被移动的状态由WAITING变为BLOCKED。
5)从wait()返回的前提是获取调用对象的锁。
3.3 等待/通知的经典范式
经典范式可以分为两部分:等待方(消费方)与通知方(生产者)。
等待方遵循原则:
1)获取对象的锁
2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
3)条件满足执行对应的逻辑
通知方遵循原则:
1)获得对象锁
2)改变条件
3)通知所有等待在对象上的线程
3.4 管道输入/输出流
管道输入/输出流用于线程间的数据传输,传输的媒介是内存。
PipedOutputStream / PipedIntputStream 字节流
PipedReader / PipedWriter 字符流
注:在使用管道流的时候要注意,一定要进行绑定,也就是调用connect()方法,否则会出异常
参考:《java并发编程的艺术》