面向对象u2-pre
OO_u2 pre
现在刚好在U1与U2衔接的空档期,笔者正好写完了作业没事干,所以决定看看U2的内容。说实话虽然老早就知道多线程、多进程这样概念,平常写代码需要多线程最多就是直接使用进程池(GPT把关版),这样深入地学习多线程还是第一次。
学习资料
1.多线程是什么
在古早时代,面对多个需要执行的程序/作业,计算机无法做到同时运行他们。于是程序只能一个一个地运行,像一个糖葫芦串一样一个接一个(好饿),这是程序的串行。
后来科学家们发现某些程序在运行时总是频繁地等待交互响应(一会需要打印机啊、一会需要输入机啊…),占用CPU资源。俗话说占着**不拉*,聪明的科学家想出了一个好办法,趁程序拉*(交互)的时候,剥夺其对CPU的使用权,交由其他程序使用,等到它拉完了又可以继续使用CPU了。由于它们交换CPU使用权又快又频繁,看起来像多个程序在同时运行。这是程序的并发。
后来随着资源越来越丰富,科学家想到给一个电脑多装几个CPU,这样程序不就能真正地一起运行了吗。所以发展出了多核CPU,两个/多个程序可以同时占用CPU资源,有不同的核(CPU)为它们提供服务。这是程序的并行
所以科学家把这一个个并发/并行的程序称为进程。在早期,进程既是系统分配资源的基本单位,也是执行的基本单元。后来,科学家们为了加大系统的并发度,又引入了线程,线程在进程之下,成为了执行的基本单元。而且由于线程的调度开销远小于进程,所以也得到了广泛的使用。多线程慢慢地不再是多进程的附庸,跨越了jieji
总之,多线程可以让我们的CPU跑的更充分。它明明可以上七休零,你要让它上五休二,不行,得狠狠压榨它
2.多线程 in Java
作为流行的编程语言,Java
自然也提供了原生的线程库java.util.concurrent
简称JUC
。我们可以调用其提供的API
快速上手多线程编程
创建线程
在Java
中有若干种方法创建一个线程,这里只介绍几种较为常用的方法
i. Thread
Thread
类是JUC
中提前定义好的线程类,我们只需要继承此类并重写run
方法即可
//MyThread.java |
欸我们刚刚不是重写了run
方法吗,为什么调用了start
方法?
- 虽然重写了
run
方法,但是如果直接调用的话,两个线程的执行实际上还是串行的,和我们之前写代码的逻辑是一样的 - 但是调用了
start
方法后,线程将接受线程调度,可以并发执行,start
方法内部也是调用了run
方法的确实可以看到//Thread.java
public synchronized void start() {
...
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
...
}
}
private native void start0();
/**
* If this thread was constructed using a separate
* <code>Runnable</code> run object, then that
* <code>Runnable</code> object's <code>run</code> method is called;
* otherwise, this method does nothing and returns.
* <p>
* Subclasses of <code>Thread</code> should override this method.
*
* @see #start()
* @see #stop()
* @see #Thread(ThreadGroup, Runnable, String)
*/
public void run() {
if (target != null) {
target.run();
}
}start
方法内部调用了start0
方法,其创建系统级进程并启动run
方法,不过似乎调用run
方法前还做了一些什么事,难道线程不是start
就启动了吗?这里先卖个关子后面在说
ii. Runnable
看到上面的源码也说了,如果线程使用了额外的Runnable
实例对象,那么就会调用实例对象的run
方法。所以我们启动线程的第二个方法就是通过Runnable
对象传递给线程。
//MyRunnable.java |
可以看到这第二种方法还是有些许不一样的,例如需要重写接口的方法,同时传递给不同Thread
类可以是同一个Runnable
实例对象,看起来很神奇,难道我不是不同的线程吗,为什么用同一个对象
- 线程可以访问共有对象,线程共用堆空间
- 线程共用实例对象方法不会有影响,线程不共用栈空间,每个线程有独立的栈
- 所以实际上是不同线程复用了同一个实例对象中方法的逻辑,两个方法的执行实际上是独立的
当然我们这里使用的多线程方法都没法返回值。如果希望多线程计算等等,有兴趣的同学可以了解Callable
和Future
的使用,这里不涉及我们的作业,就不在此展开了。
iii. 常用的属性与方法
setName(String) -> void
- 可以给你的线程取一个洋气的名字,默认是
Thread-x
(x是main线程开始后开启的线程序号0、1…)
getName() -> String
- 同上,获取线程的名字
Thread(String str)
- 当然也可以在创建线程是直接给它一个名字
{static} currentThread() -> Thread
- 执行到此语句时的线程对象,例如希望在
Runnable
对象中获取线程实例
{static} sleep(int) -> void
- 让执行此条语句的线程陷入休眠(
TMIED_WATING
)状态
setDaemon(Thread) -> void
- 为当前线程设置一个守护线程,当前线程结束,守护线程将逐步暂停
setPriority(int) -> void
- 设置线程执行优先级,优先级越高,被执行的概率越大
- 三个值
MIN_PRIORITY: 1
、NORM_PRIORITY: 5
、MAX_PRIORITY: 10
线程闹鬼事件
CVBB引入了一个马戏团,准备在下周五表演,CVBBer对此都十分感兴趣。为了照顾同学们宝贵的课余时间,主办方拜托小熊写一个多窗口的售票平台,方便同学们抢票。共计3个窗口,100张门票。事不宜迟,我们赶紧开始吧
我们先浅浅地实现一个售票机
//VendingMachine.java |
测试一下,嗯,果然运行地很好。现在我们把它改为多线程吧,无非就是实现Runnable
罢了
public class VendingMachine implements Runnable { |
aaa,完蛋了。不仅票卖重复了,甚至还卖出了假票(),这究竟是怎么回事呢
我们观察canGetTicket
方法内部的逻辑,当三个线程同时进入该方法后,按如下顺序继续执行
t1 -> ticket++ |
啊,原来线程t1
执行完票号加一操作没有立刻打印出来,CPU执行权被截胡了,最后导致了悲剧的发生。那我们到底应该如何解决这个问题呢
数据安全
刚刚讲到了线程引发的赛博闹鬼事件。那么我们应该如何避免这种现象的发生呢
观察我们刚刚发现的问题
- 多个相同的票号是因为
ticket
自增与打印值两个操作并非一气呵成的,也就是非原子操作 - 超出范围的票号是因为 判断
ticket
大小与ticket
自增两个操作并非一气呵成的,也是因为非原子操作
我们当然很难去把这种操作实现为一个底层的汇编指令。虽然这样能从根本上解决什么原子、非原子问题。但是理论上我们需要的原子操作是无限的,但是指令集是有限的。
于是我们把原子操作放在某个对象上,对于这个对象的获取与释放是原子操作,这是互斥锁的一个简陋的描述
synchronized
关键词
关于synchronized
的底层实现是极为复杂的,因为Java8之后,synchronized
的实现进行了大量的优化操作,其中涉及到锁的升级等等。但是我们可以简单地认为它是通过原子操作请求和释放锁的
1. synchronized同步代码块解决互斥
同一时间,只能有一个线程进入同步代码块执行操作,其余线程阻塞在外部
对于一个共有的变量上锁,通常可以使用.class
文件
private static Object lock = new Object(); |
2. synchronized同步方法解决互斥
和上面同步代码块实际是一致的
- 对于非静态方法,默认锁住
this
实例对象,即同一时间一个实例对象中所有的synchronized
修饰的非静态方法中只能有一个方法被一个线程使用 - 对于静态方法,默认锁住
class
类对象,即同一时间一个类中所有synchronized
修饰的静态方法只能有一个方法被一个线程使用 class
类对象和this
对象显然非同一对象,所以synchronized
修饰的静态和非静态方法之间并没有限制
public void run() { |
wait、notify解决同步问题
在上述情况下,我们只关注对于共用资源的访问同一时刻只能由一个线程执行。但是在某些情况下,不仅这种资源的访问是互斥的,顺序也是需要依照一定的规律
吃青提
小明的妈妈给他买了一盒阳光青提。妈妈开始洗青提,每洗好一个青提就会放在盘子中,直到盘子装满了为止。小明同时开始吃青提,直到盘子为空为止。已知共有50个青提,盘子最多盛放3个青提。
我们可以比较容易地看出可以为小明和妈妈都设置一个线程,一个负责放一个负责吃。而且对于盘子这个资源的访问应该是互斥的。这样是否就足够了呢???如果刚开始小明进程开始执行,发现盘子中没有青提,小明应该怎么办?
- 首先盘子是共用的资源(临界资源),所以对盘子的访问是互斥的
- 其次,线程结束的标志是青提洗完了???(真的是这个吗?)
- 小明吃完了青提,想要再次吃的时候必须要阻塞(如何阻塞),要去唤醒妈妈继续洗(如何唤醒)
- 盘子中装满了青提时,妈妈要是还想放也需要阻塞,要去通知小明吃青提
首先是盘子,这个容器最好写了,不需要任何方法,定义一些属性即可
//Plate.java |
其次就是妈妈,其实就是生产者,负责将东西放入盘子中
- 生产者会一直尝试将东西放入盘子中,直到盘子放满则阻塞,次数耗尽则死亡
wait
方法会将当前线程“挂”在锁下,并释放锁,线程进入阻塞(BLOCKED
)状态notifyAll
方法将唤醒所有被挂在该对象下的线程,但最终只能有一个线程获得执行权,其他线程继续进入阻塞状态,等待下一次唤醒抢夺CPU控制权- attention:
wait
方法需要在while
循环中使用,因为有时线程会被“虚假唤醒”,莫名地就会执行后面的语句,极不安全最后是小明,第一次写还是有很多问题的(:sob:
public void run() {
while (true) {
try {
if (produce()) {
break;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
private boolean produce() throws InterruptedException {
synchronized (Plate.lock) {
if (Plate.maxTimes == 0) {
return true;
}
while (Plate.now >= Plate.capacity) {
Plate.lock.wait();
}
Plate.now++;
Plate.maxTimes--;
System.out.println(Thread.currentThread().getName() + " produce grape, with " + Plate.maxTimes + " left");
Plate.lock.notifyAll();
return false;
}
} - 消费者进程会一直尝试从盘子中拿出食物,直到盘为空则阻塞???,次数耗尽则死亡???
- 结束的条件并不只是次数耗尽,次数耗尽指生产者已经全部生产完食物,但是消费者有没有消耗完呢?
- 假设生产者线程结束时,所有消费者都在consume方法开始,那么接下来每个消费者线程发现
Plate.maxTimes == 0
即退出,那么生产者最后生产的物品并没有被消耗
- 假设生产者线程结束时,所有消费者都在consume方法开始,那么接下来每个消费者线程发现
- 唤醒条件即当前盘是否为空,只有盘为空消费者才能继续执行下去吗?
- 假设现在生产者线程已经结束,有若干消费者进程在等待,其中一个消费者消耗了生产者最后生产的若干物品并正常退出了,剩下的消费者怎么办???**即使它们被唤醒,但是检查发现
Plate.now == 0
**又会继续等待,程序将无法终止
- 假设现在生产者线程已经结束,有若干消费者进程在等待,其中一个消费者消耗了生产者最后生产的若干物品并正常退出了,剩下的消费者怎么办???**即使它们被唤醒,但是检查发现
- 所以真正的条件应该如下,而非注释中的一般情况
//run...
private boolean consume() throws InterruptedException {
// Thread.sleep(20);
synchronized (Plate.lock) {
// if (Plate.maxTimes == 0) ???
if (Plate.maxTimes == 0 && Plate.now == 0) {
return true;
}
// while (Plate.now <= 0) ???
while (Plate.now <= 0 && Plate.maxTimes != 0) {
Plate.lock.wait();
}
if (Plate.now > 0) {
Plate.now--;
index++;
System.out.println(Thread.currentThread().getName() + " consume grape");
}
Plate.lock.notifyAll();
return false;
}
}
ReentrantLock
Lock
对象是JUC
中的显式锁,需要用户手动上锁/关锁,但是是一个接口,我们需要使用它的一个实现类ReetrantLock
例如将上面代码改写
//Plate.java |
再次运行程序,发现了神奇的一幕,程序无法结束了。这是为什么呢?
- 我们查看刚刚改过的代码,发现
return true;
时没有把锁打开,导致了锁一直处于关闭状态 - 我们应该使用
finally
块,使得无论如何都要将锁打开Plate.lock.lock();
try {
....
return true;
} finally {
Plate.lock.unlock();
}
ReadWriteLock
读写锁手动实现
读写锁,这个涉及到另一个经典的多线程问题————“读者与写者问题”
- 对于一个文件的操作可以分为读、写两种线程
- 读线程可以有多个同时一起读文件
- 写线程必须与读线程和其他写线程互斥
这个问题最有意思的一点在于,我们应该如何让多个读线程不互斥
- 这个问题和刚刚的盘子容量有些类似,只是这次盘子的容量是无限大的,所以我们可以这样考虑:永远只给第一个读线程上锁,永远只给最后一个读线程解锁但是这样还有问题,因为
//read
private Lock lock = new ReetrantLock(); //读写锁
private int count = 0; // 记录所有的读进程
if (count == 0) {
lock.lock();
}
count++;
read();// 真正的读操作
count--;
if (count == 0) {
lock.unlock();
}count == 0
与count++
不是原子操作,可能导致多个读者线程进入if
区域尝试获取锁,导致读者们相互阻塞,并不是我们希望看到的。同理下方的count
也需要保护写进程也是同理了//read
public static int Lock lock = new ReetrantLock(); //读写锁
private Lock mutex = new ReetrantLock();
private int count = 0; // 记录所有的读进程
mutex.lock();
if (count == 0) {
lock.lock();
}
count++;
mutex.unlock();
read();// 真正的读操作
mutex.lock();
count--;
if (count == 0) {
lock.unlock();
}
mutex.unlock();最后观察发现,我们实现的锁是非公平锁,甚至有些太不公平了。如果一直有读线程到来,写线程最终可能会饿死。所以我们可以多设置一个锁,保持公平。但是于此同时,程序的性能会受到一些影响。lock.lock();
write(); // 真正的写操作
lock.unlock();public static int Lock priority = new ReetrantLock();
priority.lock();
//写线程原有代码
priority.unlock();
priority.lock();
//读线程前半段获取读写锁内容
priority.unlock();
read(); //真正的读线程
//... - 这样保证了如果在读者们读文件时,写者先到来,那么接下来会轮到写者执行,
priority
锁保证了读写操作的公平性。但是实际读写锁常用于读操作显著高于写操作频率时。
ReetrantReadWriteLock
当然JUC
已经实现了我们刚刚说的读写锁。而且性能、稳定性各方面都是碾压我们手写的(毕竟它底层实现不仅有众多原子操作优化、锁优化,还能享受到JVM
级的一些优化
ReedWriteLock lock = new ReentrantReadWriteLock(); |
锁升级与降级
如果一个线程写完了之后发现自己还想读该怎么办,写锁降级为读锁
ReetrantReadWriteLock
支持重入,一个获取到写锁的线程再去获取读锁并不会阻塞自己,而是可以正常地获取如果一个线程读完之后发现自己还想写该怎么办,读锁升级为写锁???wLock.lock();
write();
//还想读
rLock.lock();
wLock.unlock();
read();
rLock.unlock();- 错误的,获取到读锁的同时不允许同时再去获取写锁,这样可能导致同时在读取的线程发生数据不一致错误,必须先释放读锁然后再尝试获取写锁
rLock.lock();
read();
rLock.unlock();
wLock.lock();
write();
wLock.unlock();
Semaphore
信号量是一个伟大的发明,JUC
的信号量实际上就是记录型自变量。信号量声明了一个资源的多少,允许多个线程去获取这种资源,但是超过拥有资源的限制后,线程将被“挂”在信号量下,等待资源被其他线程释放。
- 听着好熟悉,感觉和小明吃青提的问题很像。
- 是的,其实解决生产者消费者问题的代码实现,是我手动模拟了一个信号量。还是那句话,既然
Java
已经实现好了我们用就是了
思考一下信号量实现同步问题,因为获取信号量时如果没有默认会进入wait
状态,而释放信号量时也默认会去通知被阻塞的进程。我们的代码实际可以写的更加简洁li
- 细细分析一下生产者与消费者的同步关系,下面的“生产”、“消耗”对应着信号量的意义,是对于资源而言的
- 消费者消耗完食物 -> 通知生产者生产(消费者生产“空资源”, 生产者消耗“空资源”)
- 生产者生产食物过剩 -> 通知消费者消费(生产者生产“食物资源”,消费者消耗“食物资源”)
- 所以我们不妨设置两个意义相反的信号量用于指代资源。那理所应当的,起始时空资源大小应该等于盘子的大小,食物资源的大小应该为0
//Plate
public static int maxTimes = 100; //总的生产次数
public static Semaphore full = new Semaphore(0); //食物资源,这里为了反义词
public static ArrayList<Integer> foods = new ArrayList<>(); //盘子
public static Semaphore empty = new Semaphore(3); //空资源//Consumer
//消耗食物资源
Plate.full.acquire();
synchronized (Plate.class) {
if (Plate.maxTimes == 0 && Plate.foods.isEmpty()) {
//既然没用就归还资源
Plate.full.release();
return true;
}
int index = Plate.foods.remove(0);
consumed.add(index);
System.out.println(Thread.currentThread().getName() + " consume grape@" + index);
}
//生产空资源
Plate.empty.release();
return false;接下来我们运行发现,消费者消费完之后程序停不下来了。看看结束的情况,感觉也没问题啊,没用的资源还回去怎么会出问题呢//Producer
//获取空资源
Plate.empty.acquire();
synchronized (Plate.class) {
if (Plate.maxTimes == 0) {
//既然没用就归还资源
Plate.empty.release();
return true;
}
Plate.maxTimes--;
int index = 100 - Plate.maxTimes;
Plate.foods.add(index);
produced.add(index);
System.out.println(Thread.currentThread().getName() + " produce grape@" + index);
}
//生产食物资源
Plate.full.release();
return false; - 这里还是假设生产者生成了最后三个食物资源后死亡。接下来有三个消费者线程分别消耗一个食物资源。此时刚好所有食物也消耗完了,是时候结束进程了。这是某一个消费者线程希望通过
if (Plate.maxTimes == 0 && foods.isEmpty())
返回,但是它必须先获得一个食物资源Plate.full.acquire
,此时生产者线程已经全部死亡,而我们分析食物资源也已经耗尽,程序陷入死锁状态 - 解决方案
- 先判断一遍是否可以提前返回再获取资源(缺点:麻烦,在多个地方返回写同样的语句)
synchronized (Plate.class) {
if (Plate.maxTimes == 0 && foods.isEmpty()) {
//...
return true;
}
}
Plate.full.acquire();
synchronized (Plate.class) {
if (...) {
return true;
}
} - 让生产者线程死亡前多留一个食物资源用于退出,使用
finally
即可我们显然可以证明,多留出多少个食物资源都不会影响消费者消耗的数量,因为正常情况下生产者还会将生产的食物放入try {
Plate.empty.acquire();
synchronized (Plate.class) {
if (...) {
....
return true;
}
}
} finally {
Plate.full.release();
}foods
中。所以前面的食物资源将最后的food
消耗了之后,剩余的食物资源则被用于退出,而且退出前还会归还这个多余的食物资源,就算只有一个食物资源剩余,所有的消费者进程都是可以正常退出的不会受到任何影响!
- 先判断一遍是否可以提前返回再获取资源(缺点:麻烦,在多个地方返回写同样的语句)
最后还有一个小小的问题,为什么不能将获取资源的操作放在互斥锁内部
synchronized (Plate.class) { |
acquire()
、release()
方法使用原子操作,线程安全不需要上锁- 放在内部会造成死锁现象,我们需要遵循额反正是一条避免死锁的原则,一个对象我们保证其先获得所有执行使用的资源再给他执行的机会,而不是一边给他资源,一边让他等其他资源。
- 假设现在消费者先拿到互斥锁,然后请求食物资源。食物资源没有了,于是消费者主动放弃了CPU执行权给生产者,希望生产者生产食物资源。但是因为生产者拿不到互斥锁,导致无法生产食物资源,二者陷入了死锁困境。
总结
Java
多线程远不止这些:sob:,还有各种千奇百怪的锁(自旋锁
等等),JUC
也提供了若干优化的策略,例如无锁原子操作CompareAndSwap
、TestAndSet
等等,还有锁使用策略例如轻量锁、自旋锁、重量锁等等。多线程永无止境!