Java线程与管程技术

1.写在前面

并发编程不单单在java语言中有应用到,在其他的语言上也有用到。并发编程这个技术领域已经发展了很久了。其中技术和理论也是很多同样也是复杂的。那么有没有一种技术可以很方便地解决我们的并发问题呢?那就是管程技术。本篇博客主要介绍管程技术。然后就是Java的线程一些技术,最后再介绍一下如何用面向对象思想写好并发程序。

2.什么是管程

Java采用的是管程技术,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。而管程和信号量是等价的。所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。**管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。**在管程的发展的历史上,先后出现了三种不同的模型,分别是Hasen模型、Hoare模型和MESA模型。最广泛的是MESA模型,本篇博客主要介绍的还是MESA模型。

2.1MESA模型

并发的领域,两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。

管程解决互斥的问题:将共享变量及其对共享变量的操作统一封装起来。具体如下图:

在这里插入图片描述

管程X将共享变量queue这个操作和相关的操作入队enq()、出队deq()都封装起来了;线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保持互斥性,只允许一个线程进入管程。

管程解决同步的问题:这个比较复杂,我们先介绍一下MESA模型的主要组成部分,具体如下图:

在这里插入图片描述

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,这个等待队列就是解决线程同步问题。那是怎么解决的呢?

  • 假设有个线程T1执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里的条件变量。如果线程T1进入管程后恰好发现队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线程T1就去“队列不空”这个条件变量的等待队列中等待。
  • 再假设之后另外一个线程T2执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程T1来说已经满足了,此时线程T2通知T1,告诉它需要的条件已经满足了。当线程T1得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。

2.2java中的wait()、notify()、notifyAll()

1.wait()的正确姿势

在MESA管程中,有个一个编程的范式,就是需要在一个while循环里面调用wait()

while(条件不满足){
	wait();
}

三大模型(Hasen模型、Hoare模型、MESA模型)的一个核心的区别就是当条件满足后,如何通知相关的线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?

  • Hasen模型里面,要求notify()放在代码的最后,这样T2通知完T1后,T2就结束了,然后T1再执行,这样就能保证同一时刻只有一个线程执行。
  • Hoare模型里面,T2通知完T1后,T2阻塞,T1马上执行;等T1执行完,再唤醒T2,也能保证同一时刻只有一个线程执行。但是相比Hasen模型,T2多了一次阻塞唤醒操作。
  • MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
2.notify()何时使用
  • 所有等待的线程拥有相同的等待条件;
  • 所有等待线程被唤醒后,执行相同的操作;
  • 只需要唤醒一个线程。

2.3总结

java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。

3.Java线程的生命周期

java语言里的线程的本质上就是操作系统的线程,它们是一一对应的,以前证明过。

3.1通用的线程生命周期

线程的状态:初始状态,可运行状态,运行状态,休眠状态,终止状态。具体状态的切换图如下:
在这里插入图片描述

  • 初始状态:线程已经被创建,但是还不允许分配CPU执行。这个状态属于变成语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  • 可运行状态:现场称可以分配CPU执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配CPU执行。
  • 当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到的CPU的线程的状态转换成运行状态。
  • 运行状态的线程如果调用了一个阻塞的API或等待某个事件,那么线程的状态就会装换成休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU的使用权。当等待的事件出现了,线程就会从休眠状态转成可运行状态。
  • 线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

3.2Java中线程的生命周期

java线程中状态:NEW(初始化状态)RUNNABLE(可运行 / 运行状态) BLOCKED(阻塞状态) WAITING(无时限等待) TIMED_WAITING(有时限等待)
TERMINATED(终止状态) 具体的如下图:

在这里插入图片描述

1.RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。JVM层面并不关心操作系统调度相关的状态。Java 在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。

2.RUNNABLE 与 WAITING 的状态转换
  • 第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
  • 第二种场景,调用无参数的 Thread.join() 方法。 其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
  • 第三种场景,调用 LockSupport.park() 方法。 其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从WAITING 状态转换到 RUNNABLE。
3.RUNNABLE 与 TIMED_WAITING 的状态转换
  • 调用带超时参数的 Thread.sleep(long millis) 方法;
  • 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  • 调用带超时参数的 Thread.join(long millis) 方法;
  • 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  • 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
4.从 NEW 到 RUNNABLE 状态

Java 刚创建出来的 Thread 对象就是 NEW 状态,实现方式有两种:

  • 一种是继承 Thread 对象,重写 run() 方法。
  • 另一种是实现 Runnable 接口,重写 run() 方法,并将该实现类作为创建 Thread 对象的参数。

当调用start方法,就会从NEW的状态转成RUNNABLE 状态 。

5.从 RUNNABLE 到 TERMINATED 状态

线程执行完run()方法后,会自动转换到TERMINATED 状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。Java中有个stop()的方法,但是已经被弃用了。现在正确的姿势其实是调用interrupt()方法。

两个方法的区别:

  • stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁,这实在是太危险了。 同时suspend() 和 resume() 方法 也是一样的。
  • interrupt() 方法就温柔多了,interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测
  • 当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发InterruptedException 异常。
  • 主动监测:线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。

4.创建多少线程才是合适的?

1.为什么使用多线程?

使用多线程,本质上就是提升程序性能。衡量性能的指标:延迟和吞吐量

延迟:发生请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。

吞吐量:单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。

主要是降低延迟,提供吞吐量。

2.优化方向

优化算法,将硬件的性能发挥到极致。

分类:一个是I/O,一个是CPU。在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率。

3.创建多少线程

两种:I/O密集型计算;CPU密集型计算。

理论:

  • 对于CPU密集型的计算场景,理论上“线程的数量=CPU核数”就是最合适的。不过在工程上,线程的数量一般会设置为CPU核数+1,这样的话,当线程因为偶尔的内存也失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证CPU的利用率。
  • 对于I/O 密集型的计算场景,最佳的线程数与程序中CPU计算和I/O 操作的耗时比相关,我们可以总结出这样的一个公式:最佳线程数 =1 +(I/O 耗时 / CPU 耗时) 不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

5.为什么局部变量是线程安全的?

我们先看一个代码,具体的如下:

// 返回斐波那契数列
int[] fibonacci(int n) {
	// 创建结果数组
	int[] r = new int[n];
	// 初始化第一、第二个数
	r[0] = r[1] = 1; // ①
	// 计算 2..n
	for(int i = 2; i < n; i++) {
		r[i] = r[i-2] + r[i-1];
		}
	return r;
}

假设多个线程执行到①处,多个线程都要对数组r的第1项和第2项赋值,这里看上去感觉是存在数据竞争,不过感觉再次欺骗了你。这样我们就需要一点点编译原理的知识。

5.1方法是如何执行的

高级语言里的普通语句,例如上面的r[i] = r[i-2] + r[i-1];翻译成 CPU 的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第 1 行,声明一个 int 变量a;第 2 行,调用方法 fibonacci(a);第 3 行,将 b 赋值给 c。

int a = 7int[] b = fibonacci(a);
int[] c = b;

当你调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;的地址,再跳转到这个地址去执行。 具体的如下图:

在这里插入图片描述

到这里,方法调用的过程想必你已经清楚了,但是还有一个很重要的问题,“CPU 去哪里找到调用方法的参数和返回地址?”如果你熟悉 CPU 的工作原理,你应该会立刻想到:通过 CPU 的堆栈寄存器。 因为这个栈是和方法调用相关的,因此经常称为调用栈

例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。 利用栈结构来支持方法调用这个方案非常普遍,以至于 CPU 里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是靠栈结构解决的。Java 语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。

5.2局部变量存哪里?

局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样:

在这里插入图片描述

这个结论相信很多人都知道,因为学 Java 语言的时候,基本所有的教材都会告诉你 new出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。

5.3调用栈与线程

每个线程都有自己独立的调用栈。因为每个线程都有自己的调用栈,局部变量保存在线程各自调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。由此我们引申出一个解决并发问题的一个重要的技术,同时还有个响当当的名字叫做线程封闭。官方的解释:仅在单线程内访问数据

经典例子:

例如从数据库连接池里获取的连接 Connection,在JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。

6.如何用面向对象思想写好并发程序?

主要通过以下三个方面:封装共享变量、识别共享变量间的约束条件和制定并发访问策略

6.1封装共享变量

封装:将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性。

应用到并发上来:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。

举例:

public class Counter {
    private long value;
    synchronized long get(){
        return value;
    }
    synchronized long addOne(){
        return ++value;
    }
}

上面的例子就是一个简单的计数器,同时是线程安全的。因为这个共享的变量只有一个,比较简单。有的时候共享变量比较多,就会比较复杂。有的时候有些共享变量是不会发生改变的,这个时候我们只需要用final关键字来修饰。

6.2识别共享变量间的约束条件

识别共享变量间的约束条件非常重要。因为这些约束添加,决定了并发访问策略。

例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。在类 SafeWM 中,声明了两个成员变量 upper 和 lower,分别代表库存上限和库存下限,这两个变量用了AtomicLong 这个原子类,原子类是线程安全的,所以这个成员变量的 set 方法就不需要同步了。 约束条件:库存下限要小于库存的上限。于是写出下面的代码:

public class SafeWM {
    // 库存上限
    private final AtomicLong upper = new AtomicLong(0);
    // 库存下限
    private final AtomicLong lower = new AtomicLong(0);
    // 设置库存上限
    synchronized void setUpper(long v){
        // 检查参数合法性
        if (v < lower.get()) {
            throw new IllegalArgumentException();
        }
        upper.set(v);
    }
    // 设置库存下限
    synchronized void setLower(long v){
        // 检查参数合法性
        if (v > upper.get()) {
            throw new IllegalArgumentException();
        }
        lower.set(v);
    }
    // 省略其他业务代码
}

我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。 共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。

6.3制定并发访问策略

主要是以下三件事:

  • 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  • 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor模式、CSP 模式以及函数式编程的基础都是不变模式。
  • 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用Java 并发包提供的读写锁、并发容器等同步工具会更好。

原则三条:

  • 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
  • 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  • 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。

7.写在最后

本篇博客大概的讲了下讲了下管程,然后就是介绍了Java的线程的生命的周期,创建多少线程合适,以及介绍局部变量是线程安全的吗。同时还写了面向对象思想写好并发程序。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值