并发编程不单单在java语言中有应用到,在其他的语言上也有用到。并发编程这个技术领域已经发展了很久了。其中技术和理论也是很多同样也是复杂的。那么有没有一种技术可以很方便地解决我们的并发问题呢?那就是管程技术。本篇博客主要介绍管程技术。然后就是Java的线程一些技术,最后再介绍一下如何用面向对象思想写好并发程序。
Java采用的是管程技术,synchronized关键字及wait()、notify()、notifyAll()这三个方法都是管程的组成部分。而管程和信号量是等价的。所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。**管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。**在管程的发展的历史上,先后出现了三种不同的模型,分别是Hasen模型、Hoare模型和MESA模型。最广泛的是MESA模型,本篇博客主要介绍的还是MESA模型。
并发的领域,两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。
管程解决互斥的问题:将共享变量及其对共享变量的操作统一封装起来。具体如下图:
管程X将共享变量queue这个操作和相关的操作入队enq()、出队deq()都封装起来了;线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq()、deq()方法来实现;enq()、deq()保持互斥性,只允许一个线程进入管程。
管程解决同步的问题:这个比较复杂,我们先介绍一下MESA模型的主要组成部分,具体如下图:
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,这个等待队列就是解决线程同步问题。那是怎么解决的呢?
在MESA管程中,有个一个编程的范式,就是需要在一个while循环里面调用wait()
while(条件不满足){
wait();
}
三大模型(Hasen模型、Hoare模型、MESA模型)的一个核心的区别就是当条件满足后,如何通知相关的线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?
java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
java语言里的线程的本质上就是操作系统的线程,它们是一一对应的,以前证明过。
线程的状态:初始状态,可运行状态,运行状态,休眠状态,终止状态。具体状态的切换图如下:
java线程中状态:NEW(初始化状态)RUNNABLE(可运行 / 运行状态) BLOCKED(阻塞状态) WAITING(无时限等待) TIMED_WAITING(有时限等待)
TERMINATED(终止状态) 具体的如下图:
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。JVM层面并不关心操作系统调度相关的状态。Java 在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。
Java 刚创建出来的 Thread 对象就是 NEW 状态,实现方式有两种:
当调用start方法,就会从NEW的状态转成RUNNABLE 状态 。
线程执行完run()方法后,会自动转换到TERMINATED 状态,当然如果执行run()方法的时候异常抛出,也会导致线程终止。Java中有个stop()的方法,但是已经被弃用了。现在正确的姿势其实是调用interrupt()方法。
两个方法的区别:
使用多线程,本质上就是提升程序性能。衡量性能的指标:延迟和吞吐量
延迟:发生请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。
吞吐量:单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。
主要是降低延迟,提供吞吐量。
优化算法,将硬件的性能发挥到极致。
分类:一个是I/O,一个是CPU。在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升I/O的利用率和CPU的利用率。
两种:I/O密集型计算;CPU密集型计算。
理论:
我们先看一个代码,具体的如下:
// 返回斐波那契数列
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项赋值,这里看上去感觉是存在数据竞争,不过感觉再次欺骗了你。这样我们就需要一点点编译原理的知识。
高级语言里的普通语句,例如上面的r[i] = r[i-2] + r[i-1];翻译成 CPU 的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第 1 行,声明一个 int 变量a;第 2 行,调用方法 fibonacci(a);第 3 行,将 b 赋值给 c。
int a = 7;
int[] 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 语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。
局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样:
这个结论相信很多人都知道,因为学 Java 语言的时候,基本所有的教材都会告诉你 new出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
每个线程都有自己独立的调用栈。因为每个线程都有自己的调用栈,局部变量保存在线程各自调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。由此我们引申出一个解决并发问题的一个重要的技术,同时还有个响当当的名字叫做线程封闭。官方的解释:仅在单线程内访问数据
经典例子:
例如从数据库连接池里获取的连接 Connection,在JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。
主要通过以下三个方面:封装共享变量、识别共享变量间的约束条件和制定并发访问策略
封装:将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性。
应用到并发上来:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。
举例:
public class Counter {
private long value;
synchronized long get(){
return value;
}
synchronized long addOne(){
return ++value;
}
}
上面的例子就是一个简单的计数器,同时是线程安全的。因为这个共享的变量只有一个,比较简单。有的时候共享变量比较多,就会比较复杂。有的时候有些共享变量是不会发生改变的,这个时候我们只需要用final关键字来修饰。
识别共享变量间的约束条件非常重要。因为这些约束添加,决定了并发访问策略。
例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。在类 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 语句,所以,一定要特别注意竞态条件。
主要是以下三件事:
原则三条:
本篇博客大概的讲了下讲了下管程,然后就是介绍了Java的线程的生命的周期,创建多少线程合适,以及介绍局部变量是线程安全的吗。同时还写了面向对象思想写好并发程序。