OO_multithreading_tips

OO_multithreading_tips

Charles Lv7

前言

这周写电梯的第一周比我想象的要痛苦,主要不是老是出现WA(我的对拍器也没发挥多大作用),而是疯狂的出现异常和CPU_TLE,最后为了优化电梯运行的CPU运行时间,我甚至把十次提交都用完了才获得了不错的运行效率,事后总结,我觉得有必要对多线程的一些操作究其根本一下,故而写下此文。

进程知识的通俗介绍

什么是进程呢?在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。

所以我们可以知道,进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,可以选择多进程模式(每个进程只有一个线程),多线程模式(一个进程有多个线程)和多进程+多线程模式(复杂度最高)。

而Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。所以我们实验中所写的代码实际采用了多线程模式。

代码实现的一些注意点

在简单介绍了一下进程和线程的相关知识后,我来进一步介绍一下我们在编写电梯时的一些有关多线程的知识。

start()和run()的关系

基础款

要创建一个新线程非常容易,我们需要实例化一个Thread实例,然后调用它的start()方法,start()方法会在内部自动调用实例的run()方法,从而运行一个新的线程,就像下面几行那样。

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
class MyThread implements Runnable {
@Override
public void run() {
System.out.println("Thread start");
}
}

拓展点

这些相信大家在看了OO课程和电梯的历练后都是会的,但其实这两个方法还有一些注意点。就比如我在写电梯时,由于老是遇到电梯wait后无法notify的情况,于是就直接在调度器加入新的乘客后进行了相应电梯线程的run方法,这样在逻辑上虽然非常合理,但在JVM中的行为却是大相径庭的。

我们要知道,run()其实也就是个重写的普通方法,并不属于某个线程,换言之,直接调用run()方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。上述代码如果将t.start()改成t.run()的话,实际上是在main()方法内部又调用了run()方法,打印hello语句是在main线程中执行的,没有任何新线程被创建。这一点即使在线程已经start()后也是如此,而这显然不是我们刻意调用了run()方法的本意。

总而言之,必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的。

线程同步

线程安全的简单介绍

上面那点相信大家即使不是很清楚应该也影响不大,毕竟我当时好像也是被逼急了才给出调用run()方法这种下三滥的手段,但线程同步就是毫无疑问对于每个电梯选手必须了解的课题了。

简单介绍一下线程同步吧。在Java中,当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。这时候就不得不提到这次作业的一个重要难题——合理的加锁和解锁。

Java程序使用synchronized关键字对一个对象进行加锁,synchronized保证了代码块在任意时刻最多只有一个线程能执行,从而解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

在了解这些之后,线程锁的使用还是比较简单的,如果你使用的是非线程安全容器(如ArrayList)作为共享容器时,找出修改共享变量的线程代码块,选择一个共享实例作为锁,最后用synchronized套起来就可以了。

如何省略synchronized减少运行时间

好吧,相信这些大家都是知道的,下面我来讲一些可以不用synchronized的地方,毕竟就像前文所说的那样其实synchronized代码块会增加代码运行时间的。

首先,单条原子操作的语句不需要同步。原子操作是在操作系统的辅助下可以一步执行完的语句(引语),所以他不存在线程安全的风险,所以也就没有嵌套的synchronized的必要。如下面这个例子中的synchronized就可以省略。

1
2
3
4
5
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}

除此之外,synchronized关键词虽然表面上叫做对于读写的安全加锁,但我们可以简单考虑一下,如果两个线程同时要获得一个共享数据的值,他们同时读取这个数据的值并不会有什么影响,换言之,我们真正需要加锁的线程是那些写操作的线程,只要保证共享数据完成被改写的过程中不会被其他线程读取,也就完成了线程安全的功能了。

synchronized在方法中的使用

我们知道Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装,更好的方法是把synchronized逻辑封装起来。

换言之,我们可以自己定义一个线程安全的容器,在源头上解决线程同步问题,这样在其他类里使用这个容器的数据时也可以更加省心。(其实java中有许多帮你包装好的线程安全容器,如CopyOnWriteArrayList,但我觉得这样没有达到练习的目的,所以没有选择走这条捷径,但去看一看这些线程安全容器的内部代码书写还是非常涨知识的)。

而在容器内部,我们就不可避免的要将一些方法设为synchronized的,那我们要怎么理解synchronized的方法呢,我写一个简单的例子,相信大家就明白了。

1
2
3
public synchronized void add(int n) {
count += n;
}

这是一个被synchronized了的代码,将其用我们容器的synchronized来转化其实可以写成下面几行语句,他们在使用时是完全相同的。

1
2
3
4
5
public void add(int n) {
synchronized(this) {
count += n;
}
}

因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁,这样理解也就易懂了许多。

我们再思考一下,如果对一个静态方法添加synchronized修饰符,它锁住的是哪个对象?

对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的Class实例。上述synchronized static方法实际上相当于:

1
2
3
4
5
6
7
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}

wait()和notifyAll()

在debug过程中,我经历了很多不同的TLE的情况,所有都是拜这两个函数所赐,一会是notifyAll()多了导致轮询最终CPU_TIME_TLE,一会又是notifyAll()少了导致某些线程不能顺利结束造成REAL_TIME_TLE,感觉自己就不停在两个极端反复横跳,究其原因,一方面是我没有使用线程安全容器而是可以使用synchronized导致编码难度偏高,另一方面也确实对多线程不够了解,所以在此简单总结一下并给出一些细节点。(当然帆姐那篇已经讲的很好了,在此引流一下——学习《图解JAVA多线程设计模式》分享

基础知识

在Java程序中,synchronized解决了多线程竞争的问题,但是synchronized并没有解决多线程协调的问题。我们在使用while()循环时,如果没有wait(),while()循环永远不会退出。因为线程在执行while()循环时,已经在函数入口获取了this锁,其他线程根本无法调用该函数,因为该函数执行条件也是获取this锁。

因此,多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。

使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个)。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

一个细节点

首先重要的事情说三遍,我们一定要在在while()循环中调用wait(),而不是if语句,这个是我真的使我de了很久非常痛苦的bug。

为什么一定要用while呢,因为线程被唤醒时,需要再次获取this锁,而在每部电梯都wait()一次之后,锁又自动回到了第一部电梯的手上,此时已经没有wait()了,也就跳出了if语句,最终导致如果长时间没有乘客加入,就会造成非常严重的轮询。所以,要始终在while循环中wait(),并且每次被唤醒后拿到this锁就必须再次判断。

所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。

  • Title: OO_multithreading_tips
  • Author: Charles
  • Created at : 2023-03-26 16:47:59
  • Updated at : 2023-03-26 18:30:26
  • Link: https://charles2530.github.io/2023/03/26/oo-multithreading-tips/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments