文章目录
前言
在多线程与高并发编程之基础知识(上)一文中,对于线程的一些概念性的基础知识进行阐述,但是,实际开发中,更需要关注一些多线程编程面临的问题和挑战,多线程编程会面临哪些问题,这些问题又是如何产生的以及该如何解决这些问题等等,这些将是本文需要主要讲解的内容,在进行主要内容的叙述之前,先介绍一些相关的基础知识,并引出多线程编程中问题的产生原因,最后,再对实际开发中多线程编程问题进行描述、分析以及解决;
线程调度
Java线程的实现:Java线程模型是基于操作系统原生线程模型来实现的;
线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编写和运行过程来说,并没有什么不同;
线程优先级
时分形式是现代操作系统采用的基本线程调度形式,操作系统将CPU资源分为一个个的时间片,并分配给线程,线程使用获取的时间片执行任务,时间片使用完之后,操作系统进行线程调度,其他获得时间片的线程开始执行;那么,一个线程能够分配得到的时间片的多少决定了线程使用多少的处理器资源,线程优先级则是决定线程可以获得多或少的处理器资源的线程属性;
可以通过设置线程的优先级,使得线程获得处理器执行时间的长短有所不同,但采用这种方式来实现线程获取处理器执行时间的长短并不可靠(因为系统的优先级和Java中的优先级不是一一对应的,有可能Java中多个线程优先级对应于系统中同一个优先级);Java中有10个线程优先级,从1(Thread.MIN_PRIORITY)到10(Thread.MAX_PRIORITY),默认优先级为5;
因此,程序的正确性不能够依赖线程优先级的高低来判断;
线程调度
线程调度是指系统为线程分配处理器使用权的过程;主要调度方式有:抢占式线程调度、协同式线程调度;
抢占式线程调度
每个线程由系统来分配执行时间,线程的切换不由线程本身决定;Java默认使用的线程调度方式是抢占式线程调度;我们可以通过Thread.yield()使当前正在执行的线程让出执行时间,但是,却没有办法使线程去获取执行时间;
协同式线程调度
每个线程的执行时间由线程本身来控制,线程执行完任务后主动通知系统,切换到另一个线程上;
两种线程调度方式的优缺点
协同式的优点:实现简单,可以通过对线程的切换控制避免线程安全问题;
协同式的缺点:一旦当前线程出现问题,将有可能影响到其他线程的执行,最终可能导致系统崩溃;
抢占式的优点:一个线程出现问题不会影响到其他线程的执行(线程的执行时间是由系统分配的,因此,系统可以将处理器执行时间分配给其他线程从而避免一个线程出现故障导致整个系统崩溃的现象发生);
结论
在Java中,线程的调度策略主要是抢占式调度策略,正是因为抢占式调度策略,导致多线程程序执行过程中,实际的运行过程与我们逻辑上理解的顺序存在较大的区别,也就是多线程程序的执行具有不确定性,从而会导致一些线程安全性问题的发生;那么,什么是线程安全呢?
线程安全
线程安全的定义
简单来说,线程安全就是对于多个线程并发执行的操作不需要进行任何外部的控制,也不需要进行任何的协调,就能够保证程序的执行结果与开发人员的预期结果保持一致,那么这个多线程程序就是线程安全的;
注意:
线程安全问题一定是基于多个线程之间存在访问共享数据这一前提下的;如果多个线程之间不会访问同一个变量,那么就不存在线程安全的问题;
线程安全的分类
线程安全这一概念并不仅仅分为线程安全和非线程安全,按照线程安全的强弱程度可以将各种共享变量的操作分为:不可变、绝对线程安全、相对线程安全、线程兼容以及线程对立这五种情况;
- 不可变:如果共享变量是不可变的对象,那么对该共享变量的多线程操作一定是线程安全的,因为对象是不可变的,所以任何线程都不可以改变共享变量的状态,也就不会出现脏读等现象;
- 如果共享变量是一个基本数据类型的变量,那么可以使用final关键字保证其是不可变的;
- 如果共享变量是一个对象,那么就需要保证对象的行为不会改变该对象的状态,可以将一个类的所有字段使用final关键字修饰,那么就可以保证该类的对象是不可变的,如java.lang.String类;
- 绝对线程安全:不需要在调用端进行任何同步处理,就能保证代码在多线程并发的场景下保证线程安全的,即多线程并发执行的结果符合预期的结果;Java API中标注为线程安全的类,大多数都不是绝对线程安全;
- 相对线程安全:Java API中标注为线程安全的类,大多数都是相对的线程安全,也就是通常意义上的线程安全,保证对共享变量单独操作时是线程安全的,调用时可以不用额外的保障措施;例如Vector、HashTable或通过Collections的synchronizedCollection()方法包装的集合等;
- 线程兼容:线程兼容指对象本身并不是线程安全的,但是**可以通过在调用端正确采用同步手段来保证对象在并发环境中可以安全地使用,是通常意义上的非线程安全;Java API中的大部分类都是线程兼容的,**例如ArrayList、HashMap等;
- 线程对立:无论调用端采用什么同步措施都不能保证多线程环境中的线程安全;线程对立很少出现;
线程安全问题的解决方法
介绍了线程的调度原理之后,其实可以分析出线程安全问题的起因在于多线程的执行顺序具有不确定性,那么当多个线程同时操作一份资源就不出现意想不到的情况,而编译器和处理器会对执行的指令进行重排序,这些因素导致了线程安全问题;
那么,在实际开发中,我们一般需要解决的都是上述的相对线程安全以及线程兼容这两种线程安全性问题;那么,对于这两类问题,又可以细分为可见性、原子性以及有序性这三类问题;这里暂且先不进行细分,就线程安全问题,我们给出常用解决措施;
线程安全问题重现
下面结合具体的代码来看一下使用多线程编程时可能出现的线程安全问题:
package com.thread;
public class ThreadSafe implements Runnable{
//静态变量,所有对象共享
private static int count = 0;
@Override
public void run() {
for(int i = 0 ; i < 100 ; i++){
count();
}
}
public void count(){
try {
Thread.currentThread().sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
ThreadSafe threadSafe1 = new ThreadSafe();
ThreadSafe threadSafe2 = new ThreadSafe();
Thread thread1 = new Thread(threadSafe1);
Thread thread2 = new Thread(threadSafe2);
thread1.start();
thread2.start();
Thread.currentThread().sleep(1000);
System.out.println(count);
}
}
运行结果:
这一段代码的目的是开启两个线程对同一个变量分别进行100次的累加,按照正常的逻辑(串行化执行),累加后的结果应该为200,但是实际输出的结果却是190,显然这和我们的预期结果不同,这就是线程安全问题;我们分析一下,为什么会出现这样的情况,之前提到过,多线程执行的时候代码执行的顺序具有不确定性,那么就可能出现,线程1(thread1)在获取到count的值之后,CPU执行权被分配给了线程2(thread2),线程2获取到的值与线程1获取到的相同,那么两个线程累加操作执行后,相当于只累加来一次,这样就会导致线程不安全问题产生;那么,如何解决这个问题,我们可以利用Java中的synchronized关键字对线程体进行同步,代码如下:
package com.thread;
public class ThreadSafeTwo implements Runnable{
//静态变量,所有对象共享
private static int count = 0;
@Override
public void run() {
//这里对线程体进行同步
synchronized(ThreadSafeTwo.class){
for(int i = 0 ; i < 100 ; i++){
count();
}
}
}
public void count(){
try {
Thread.currentThread().sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
ThreadSafeTwo threadSafe = new ThreadSafeTwo();
Thread thread1 = new Thread(threadSafe);
Thread thread2 = new Thread(threadSafe);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
同步处理后代码执行的结果如下:
显然,经过同步后的代码,就可以保证多线程并发执行的情况下,结果依然符合预期结果;关于synchronized关键字的实现原理将会另起一文进行分析,下面我们看一下,synchronized关键字的使用方式有哪些?
synchronized关键字的使用方式
- synchronized同步代码块
- 锁的对象为指定的对象
- synchronized同步实例方法
- 锁的对象为当前实例
- synchronized同步静态方法
- 锁的对象为Class对象
synchronized关键字的应用实例
线程安全的单例模式实现
package com.thread;
public class SingleTonThreadSafe {
//属性私有化,volatile实现内存可见性、禁止指令重排序
private volatile static SingleTonThreadSafe singleTonThreadSafe = null;
//无参构造函数私有化
private SingleTonThreadSafe(){}
//静态方法外部使用,获取对象实例
public static SingleTonThreadSafe getInstance(){
//第一次判断,避免不必要的加锁
if(singleTonThreadSafe == null){
//同步实例化代码块
synchronized(SingleTonThreadSafe.class){
//再次检测,避免其它线程已经实例化
if(singleTonThreadSafe == null){
//实例化,其他线程立即可见
singleTonThreadSafe = new SingleTonThreadSafe();
}
}
}
return singleTonThreadSafe;
}
}
synchronized同步锁的使用注意点
- 死锁
- 定义:多个线程互相等待已被对方占有的锁,同时都不释放自己已经占有的锁,导致线程之间陷入僵持,致使系统不可用
- 形成条件:互斥锁、锁只能主动释放、循环等待
- 避免策略:顺序加锁、超时获取自动放弃、死锁检测
- 活锁
- 定义:线程等待被其他线程唤醒,但是实际没有线程来唤醒,导致线程一直无法恢复到运行状态
- 避免策略:编程时有等待,就必须有对应的唤醒
线程间通信
如果你的多线程程序仅仅是每个线程独立完成各自的任务,相互之间并没有交互和协作,那么,你的程序是无法发挥出多线程的优势的,只有有交互的多线程程序才是有意义的程序,否则,还不如使用单线程执行多个方法实现程序来的简单、易懂、有效!
那么,线程间进行交互通信的手段有哪些呢?下面,将给出常用的多线程通信的实现手段以及相应的代码示例,并结合具体的代码进行分析,对其中需要注意的地方进行突出提示;
等待通知机制
我们先看这样一个场景:线程A修改了对象O的值,线程B感知到对象O的变化,执行相应的操作,这样就是一个线程间交互的场景;可以看出,这种方式,相当于线程A是发送了消息,线程B接收到消息,进行后续操作,是不是很像生产者与消费者的关系?我们都知道,生产者与消费者模式可以实现解耦,使得程序结构上具备伸缩性;那么Java中如何实现这种功能呢?
一种简单的方式是,线程B每隔一段时间就轮询对象O是否发生变化,如果发生变化,就结束轮询,执行后续操作;
但是,这种方式不能保证对象O的变更及时被线程B感知,同时,不断地轮询也会造成较大的开销;分析这些问题的症结在哪?其实,可以发现状态的感知是拉取的,而不是推送的,因此才会导致这样的问题产生;
那么,我们就会思考,如何将拉取变为推送来实现这样的功能呢?
这就引出了Java内置的经典的等待/通知机制,通过查看Object类的源码发现,该类中有三个方法,我们一般不会使用,但是在多线程编程中,这三个方法却是能够大放异彩的!那就是wait()/notify()/notifyAll();
/**
* 调用此方法会导致当前线程进入等待状态直到其它线程调用同一对象的notify()或者notifyAll()方法
* 当前线程必须拥有对象O的监视器,调用了对象O的此方法会导致当前线程释放已占有的监视器,并且等待
* 其它线程对象O的notify()或者notifyAll()方法,当其它线程执行了这两个方法中的一个之后,并且
* 当前线程获取到处理器执行权,就可以尝试获取监视器,进而继续后续操作的执行
* 推荐使用方式:
* synchronized (obj) {
* while (<condition does not hold>)
* obj.wait();
* ... // Perform action appropriate to condition
* }
* @throws IllegalMonitorStateException 如果当前线程没有获取到对象O的监视器时,抛出异常
* @throws InterruptedException 如果在调用了此方法之后,其他线程调用notify()或者notifyAll()
* 方法之前,线程被中断,则会清除中断标志并抛出异常
*/
public final void wait() throws InterruptedException {
wait(0);
}
/**
* 唤醒等待在对象O的监视器上的一个线程,如果多个线程等待在对象O的监视器上,那么将会选择其中的一个进行唤醒
* 被唤醒的线程只有在当前线程释放锁之后才能够继续执行.
* 被唤醒的线程将会与其他线程一同竞争对象O的监视器锁
* 这个方法必须在拥有对象O的监视器的线程中进行调用
* 同一个时刻,只能有一个线程拥有该对象的监视器
* @throws IllegalMonitorStateException 如果当前线程没有获取到对象O的监视器时,抛出异常
*/
public final native void notify();
/**
* 唤醒等待在对象O的监视器上的所有线程
* 被唤醒的线程只有在当前线程释放锁之后才能够继续执行.
* 被唤醒的线程将会与其他线程一同竞争对象O的监视器锁
* 这个方法必须在拥有对象O的监视器的线程中进行调用
* 同一个时刻,只能有一个线程拥有该对象的监视器
* @throws IllegalMonitorStateException 如果当前线程没有获取到对象O的监视器时,抛出异常
*/
public final native void notifyAll();
下面看一下如何通过这三个方法实现经典的等待通知机制吧!
按照JDK中推荐的使用方式实现了等待通知样例代码如下:
package com.thread;
public class WaitAndNotify {
//轮询标志位
private static boolean stop = false;
//监视器对应的对象
private static Object monitor = new Object();
//等待线程
static class WaitThread implements Runnable{
@Override
public void run() {
synchronized(monitor){
//循环检测标志位是否变更
while(!stop){
try {
//标志位未变更,进行等待
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//被唤醒后获取到对象的监视器之后执行的代码
System.out.println("Thread "+Thread.currentThread().getName()+" is awakened at first time");
stop = false;
}
//休眠1秒之后,线程角色转换为唤醒线程
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//与上述代码相反的逻辑
synchronized(monitor){
while(stop){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
monitor.notify();
stop = true;
System.out.println("Thread "+ Thread.currentThread().getName()+" notifies the waitted thread at first time");
}
}
}
//通知线程
static class NotifyThread implements Runnable{
@Override
public void run() {
synchronized (monitor){
while(stop){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
stop = true;
monitor.notify();
System.out.println("Thread "+ Thread.currentThread().getName()+" notifies the waitted thread at first time");
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (monitor){
while(!stop){
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Thread "+Thread.currentThread().getName()+" is awakened at first time");
}
}
}
public static void main(String[] args){
Thread waitThread = new Thread(new WaitThread());
waitThread.setName("waitThread");
Thread notifyThread = new Thread(new NotifyThread());
notifyThread.setName("notifyThread");
waitThread.start();
notifyThread.start();
}
}
通过上述代码,可以提炼出等待通知机制的经典模式:
等待方实现步骤:
- 加锁同步
- 条件不满足,进入等待,被唤醒之后,继续检查条件是否满足(循环检测)
- 条件满足,退出循环,继续执行后续代码
对应的伪代码:
synchronized(obj){
while(condition不满足){
obj.wait();
}
//后续操作
}
通知方实现步骤:
- 加锁同步
- 条件不满足,跳过循环检测
- 设置条件并唤醒线程
对应的伪代码:
synchronized(obj){
while(condition不满足){
obj.wait();
}
更新condition
obj.notify();
//后续操作
}
生产者消费者模式
基于等待通知机制,我们可以很容易地写出生产者消费者模式的代码,下面给出一个实现样例代码:
package com.thread;
public class ProducerAndConsumer {
//商品库存
private static int storeMount = 0;
//监视器对应的对象
private static Object monitor = new Object();
//生产者线程
static class ProducerThread implements Runnable{
@Override
public void run() {
try {
produce();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void produce() throws InterruptedException {
while(true){
synchronized(monitor){
//循环检测库存是否大于0,大于0表示还有商品可以消费,线程等待消费者消费商品
while(storeMount > 0){
monitor.wait();
}
//被唤醒后获取到对象的监视器之后执行的代码
System.out.println("Thread "+Thread.currentThread().getName()+" begin produce goods");
//生产商品
storeMount = 1;
//唤醒消费者
monitor.notify();
Thread.sleep(1000);
}
}
}
}
//消费者线程
static class ConsumerThread implements Runnable{
@Override
public void run() {
try {
consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void consume() throws InterruptedException {
while(true){
synchronized (monitor){
//检测库存是否不为0,如果不为0,那么有商品可供消费,否则等待生产者生产商品
while(storeMount == 0){
monitor.wait();
}
//消费商品
storeMount = 0;
//唤醒生产者线程
monitor.notify();
System.out.println("Thread "+Thread.currentThread().getName()+" begin consume goods");
Thread.sleep(1000);
}
}
}
}
public static void main(String[] args){
Thread producerThread = new Thread(new ProducerThread());
producerThread.setName("producerThread");
Thread consumerThread = new Thread(new ConsumerThread());
consumerThread.setName("consumerThread");
producerThread.start();
consumerThread.start();
}
}
执行结果如下图所示:
上述代码示例演示了一个生产者生产商品和一个消费者消费商品的场景,对于一个生产者多个消费者、多个生产者一个消费者、多个生产者多个消费者等场景,只需要将唤醒的方法换为notifyAll()即可,否则,会出现饥饿现象!
总结
以上就是本文叙述的所有内容,本文首先对于给出Java中线程调度形式,引出多线程编程中需要解决的线程安全问题,并分析线程安全问题,给出解决线程安全问题的常用手段(加锁同步),最后,结合Java内置的等待通知机制,进行了样例代码的展示以及分析,给出了经典的等待通知机制的编程范式,最后,基于等待通知机制给出了生产者消费者模式的实现样例,希望本文能给想要学习多线程编程的朋友一点帮助,如有不正确的地方,还望指出,十分感谢!
注意细节
- 线程分类
- 用户线程:大多数线程都是用户线程,用于完成业务功能
- 守护线程:支持型线程,主要用于后台调度以及支持性工作,比如GC线程,当JVM中不存在非守护线程时,JVM将会退出
- Thread.setDaemon(true)来设置线程属性为守护线程,该操作必须在线程调用start()方法之前执行
- 守护线程中的finally代码块不一定会执行,因此不要寄托于守护线程中的finally代码块来完成资源的释放
- 线程交互的方式
- join
- sleep/interrupt
- wait/notify
- 启动线程的方式
- 只能通过线程对象调用start()方法来启动线程
- start()方法的含义是,当前线程(父线程)同步告知虚拟机,只要线程规划期空闲,就应该立即启动调用了start()方法的线程
- 线程启动前,应该设置线程名,以便使用Jstack分析程序中线程运行状况时,起到提示性作用
- 终止线程的方式
- 中断检测机制
- 线程通过调用目标线程的interrupt()方法对目标线程进行中断标志,目标线程通过检测自身的中断标志位(interrupted()或isInterrupted())来响应中断,进行资源的释放以及最后的终止线程操作;
- 抛出InterruptedException异常的方法在抛出异常之前,都会将该线程的中断标志位清除,然后抛出异常
- suspend()/resume()(弃用)
- 调用后,线程不会释放已经占有的资源,容易引发死锁问题
- stop()(弃用)
- 调用之后不一定保证线程资源的释放
- 中断检测机制
- 锁释放的情况:
- 同步方法或同步代码块的执行结束(正常、异常结束)
- 同步方法或同步代码块锁对象调用wait方法
- 锁不会释放的情况:
- 调用Thead类的静态方法yield()以及sleep()
- 调用线程对象的suspend()