多线程基础
多线程的实现
多线程的实现在 java 中有两种
- 继承 Thread,但是这种方式有局限性,就是 java 的 单继承局限问题
- 实现 Runnable 接口。使用接口定义的线程类会更加合理。
继承 Thread 类
任何类只需要继承 Thread 类就可以成为一个线程的主类。但是既然是主类就必须有它的使用方法,而线程启动的主方法需要重写 Thread 类中的 run() 方法实现。
1 | public class ThreadExtendTest extends Thread { |
所有线程与进程是一样的,都必须轮流去抢占资源,所以多线程的执行应该是多个线程彼此交替执行。也就是说,如果直接调用 run() 方法,并不能启动多线程,多线程启动的唯一方法就是 Thread 类中的 start() 方法:public void start() (调用此方法执行的方法体是 run() 方法定义的代码)
1 | class TestDemo { |
上面的程序首先实例化了 3 个线程类对象,然后调用了通过 Thread 类继承而来的 start() 方法,进行多线程的启动。通过上面程序可以发现所有的线程都是交替运行的。
实现 Runnable 接口
使用 Thread 类的确可以方便的进行多线程的实现,但是这种方式的最大的缺点就是单继承的问题。为此,在 Java 中也可以利用 Runnable 接口来实现多线程,而这个接口的定义如下:可以看出来是函数式接口,可以用 lambda 表达。
1 |
|
在 Runnable 接口中也定义了 run() 方法,所以线程的主类只需要重写此方法即可。
1 | public class InterfaceThreadTest implements Runnable{ |
以上程序实现了 Runnable 接口并且正常重写了 run() 方法,但是却会存在一个新的问题:要启动多线程,一定需要通过 Thread 类中的 start() 方法才可以完成。如果继承了 Thread 类,那么可以直接将 Thread 父类中的 start() 方法继承下来继续使用,而 Runnbale 接口并没有提供可以被继承的 start() 方法,这时候应该如何启动多线程呢?此时可以观察 Thread 类中提供的一个有参构造方法:public Thread(Runnable target),本方法可以接收一个 Runnbale 接口对象。
1 | class InterfaceThreadTestDemo { |
本程序首先利用 Thread 类的对象包装了 Runnable 接口对象实例(new Thread(t1).start()),然后利用 Thread 类的 start() 方法就可以实现多线程的启动。
在上面 Runnable 接口代码处提到过,该接口使用了函数式接口,所以也可以用 Lambda 表达式编写代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 class LamdaRunnableDemo {
public static void main(String[] args) {
// Lambda 风格编写线程A
new Thread(() -> {
for (int i = 0; i < 200; i++) {
System.out.println("线程A--->" + i);
}
}).start();
// Lambda 风格编写线程B
new Thread(() -> {
for (int i = 0; i < 200; i++) {
System.out.println("线程B--->" + i);
}
}).start();
}
}
使用 Runnable 接口可以有效避免单继承局限问题,所以在实际的开发中,对于多线程的实现首先选择的就是 Runnable 接口。
多线程两种实现方式的区别
实现 Runnable 接口 可以避免单继承的局限
Thread 类也是 Runnable 接口的子类
1
public class Thread implements Runnable
使用 Runnable 接口可以更加方便的表示出数据共享的概念
通过继承 Thread 类实现卖票程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27public class SellTicketExtend extends Thread {
// 5张票
private int ticket = 5;
// 定义线程主方法
public void run() {
for (int i = 0; i < 50; i++) {
if (ticket > 0) {
System.out.println("卖票,ticket = " + this.ticket--);
}
}
}
}
class SellTicketExtendTest {
public static void main(String[] args) {
// 创建线程对象
SellTicketExtend s1 = new SellTicketExtend();
SellTicketExtend s2 = new SellTicketExtend();
SellTicketExtend s3 = new SellTicketExtend();
// 启动线程
s1.start();
s2.start();
s3.start();
}
} 本程序定义了 3 个线程对象,希望 3 个线程对象同时卖 5 张车票。而最终的结果是一共卖出了 15 张票,等于每一个线程各自卖各自的 5 张票。
利用 Runnable 接口实现多线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public class SellTicketRunnable implements Runnable {
private int ticket = 5; // 定义 5 张票
public void run() {
for (int i = 0; i < 50; i++) {
if (ticket > 0) {
System.out.println("卖票,ticket = " + this.ticket--);
}
}
}
}
class SellTicketRunnableTest {
public static void main(String[] args) {
// 实例化线程对象
SellTicketRunnable runnable = new SellTicketRunnable();
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
}
}
/**
卖票,ticket = 5
卖票,ticket = 3
卖票,ticket = 2
卖票,ticket = 4
卖票,ticket = 1
*/ 本程序使用 Runnable 接口实现了多线程,同时启动了 3 个线程对象,但是与继承 Thread 操作的卖票例子不同,这 3 个线程对象都占着同一个 Runnable 接口对象的引用,所以实现了数据共享的操作。
需要注意的是,继承 Thread 类的操作也可以实现数据共享的概念,但是这种实现方式往往不被采用,原因是:如果想要启动多线程肯定要依靠 Thread 类的 start() 方法,但是依靠 Runnable 接口实现的线程主题类没有 start() 方法的定义,而继承了 Thread 实现的线程主体类存在 start() 方法的定义,如果通过 Thread 类继承的多线程主体类,再利用 Thread 类去实现多线程,这样明显不合适。
总结:多线程的两种实现方式及区别
- 多线程的两种实现方式都需要一共线程的主类,而这个类可以实现 Runnable 接口或继承 Thread 类,不管使用何种方式都必须在子类中重写 run() 方法,此方法为线程的主方法;
- Thread 类是 Runnable 接口的子类,并且使用 Runnable 接口可以避免单继承局限,并且可以更加方便的实现数据共享的概念。
线程的操作状态
要想实现多线程,必须在主线程中创建新的线程对象。任何线程一般都具有 5 种状态,即创建、就绪、运行、堵塞和终止
创建状态
在程序中用构造方法创建一个线程对象后,新的线程对象便处于新建状态,此时,它已经有相应的内存空间和其他资源,但还处于不可运行状态。新建一个线程对象可采用 Thread 类构造方法来实现,例如:Thread t = new Thread().
就绪状态
新建线程对象后,调用该线程的 start() 方法就可以启动线程。当线程启动时,线程就进入了就绪状态。此时,线程将进入线程队列排队,等待 CPU 服务,这表明它已经具备了运行条件。
运行状态
当就绪状态的线程被调用并获得处理器资源时,线程就进入了运行状态,此时,自动调用该线程对象的 run() 方法。run() 方法定义了该线程的操作和功能。
堵塞状态
一个正在执行的线程在某些特殊情况下,如人为挂起或需要执行耗时的输入输出操作时,将让出 CPU 并暂时中止自己的执行,进入堵塞状态。在可执行状态下,如果调用 sleep()、suspend()、wait()等方法,线程都将进入阻塞状态。堵塞时候,线程不能进入排队队列,只有当引起堵塞的原因被消除后,线程才可以转入就绪状态。
终止状态
线程调用 stop() 方法或 run() 方法执行结束后,就处于终止状态。处于终止状态的线程不再具有继续运行的能力。
多线程常用操作方法
线程命名和获取
方法 | 类型 | 描述 |
---|---|---|
public Thread(Runnable target, String name) | 构造 | 实例化线程对象,接收 Runnable接口子类对象,同时设置线程名称 |
public final void setName(String name) | 普通 | 设置线程名字 |
public final String getName() | 普通 | 获取线程名字 |
由于多线程的状态不确定,所以线程的名字就成为了唯一的分辨标记,则在定义线程名称时一定要在线程启动前设置名字,而尽量不要崇明,且尽量不要为已经启动的线程修改名字。
由于线程的状态不确定,所以每次可以操作的都是正在执行 run() 方法的线程,那么取得当前线程对象的方法为:public static Thread currentThread()。
1 | public class GetNameThread implements Runnable { |
通过本程序可以发现,当实例化 Thread 类对象时可以自己定义线程名称,也可以采用默认名称进行操作。在 run() 方法中可以使用 currentThread() 取得当前线程对象后再取得具体的线程名字。
线程的休眠
线程的休眠指的是让程序的执行速度变慢一些,在 Thread 类中线程休眠操作方法为: sleep(long millis) ,单位:毫秒(ms)。
1 | public class SleepThread implements Runnable { |
本程序在每一次线程执行 run() 方法时候都会产生 1s 左右的延迟后才会进行内容的输出,所以整体代码执行速度有所降低。
建议设置多个线程试试
线程的优先级
在java的线程操作中,所有的线程在运行前都会保持就绪状态,此时哪个线程的优先级高,哪个线程就有可能会先被执行。
如果想要进行线程优先级的设置,在thread类中提供了支持的方法及常量。
方法或常量 | 类型 | 描述 |
---|---|---|
public static final int MAX_PRIORITY | 常量 | 最高优先级,数值为10 |
public static final int NORM_PRIORITY | 常量 | 中等优先级,数值为5 |
public static final int MIN_PRIORITY | 常量 | 最低优先级,数值为1 |
public final vod setPriority(int newPriority) | 普通 | 设置线程优先级 |
public final int getPriority() | 普通 | 获取线程优先级 |
1 | public class PriorityThead implements Runnable { |
本程序定义了3个线程对象,并在线程对象启动前,利用setPriority()方法修改了一个线程的优先级。
线程的同步与死锁
程序利用线程可以进行更为高效的程序处理,如果在没有多线程的程序中,一个程序在处理某某些资源时候会有主方法(主线程全部进行处理),但是这样的处理速度一定会比较慢。如果采用了多线程的处理机制,利用主线程创建出许多子线程(相当于多了许多帮手),一起进行资源的操作,那么执行效率一定会比只有一个主线程更高。
在程序开发中,所有程序都是通过主方法执行的,而主方法本身就属于一个主线程,所以通过主方法拆关键的新的线程对象都是子线程。利用子线程可以进行异步的操作处理,这样可以在不影响主线程运行的前提下进行其他操作,程序的执行速度不仅变快了,并且操作起来也不会产生太多的延迟。
虽然使用多线程同时处理资源效率比单线程高许多,但是多个线程如果操作同一个资源一定会存在一些问题,如资源操作的完整性问题。
同步问题的引出
同步是多线程开发中的一个重要概念,既然有同步,就一定会存在不同步的操作。
多个线程操作同一资源就有可能出现不同步的问题,例如:现在产生N个线程对象实现卖票操作,同时为了更加明显的观察不同步所带来的问题,所以本程序将使用线程的休眠操作。
1 | public class SynchronousThread implements Runnable { |
本程序模拟了一共卖票程序的实现,其中将有4个线程对象共同完成卖票的任务,为了保证每次有甚于票数时实现卖票操作,在卖票前添加了一个判断的条件(if(this.ticket > 0)),满足此条件的线程对象才可以卖票,不过根据最终的结果却发现,这个判断条件的作用并不明显。
同步操作
为了解决线程不同步的问题,就必须使用同步操作。所谓同步操作就是一个代码块中的多个操作在同一个时间段内只能有一个线程进行,其他线程要等待此线程完成后才可以继续执行,即上锁。
在java里面如果想要实现线程的同步,操作可以使用 synchronized 关键字。synchronized 关键字可以通过以下两种方式进行使用。
同步代码块:利用 synchronized 包装的代码块,但是需要指定同步对象,一般设置为 this;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42public class SynchronizedThread implements Runnable{
private int ticket = 5;
public void run() {
for (int i = 0; i < 20; i++) {
// 定义同步代码块
synchronized (this) {
// 判断是否还有剩余票
if (this.ticket > 0) {
try {
// 休眠 1s,模拟延迟
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() +
" 卖票,ticket = " + this.ticket--);
}
}
}
}
}
class SynchronizedThreadTest {
public static void main(String[] args) {
SynchronizedThread thread = new SynchronizedThread();
new Thread(thread, "线程A").start();
new Thread(thread, "线程B").start();
new Thread(thread, "线程C").start();
new Thread(thread, "线程D").start();
}
}
/**
线程D 卖票,ticket = 5
线程B 卖票,ticket = 4
线程B 卖票,ticket = 3
线程A 卖票,ticket = 2
线程A 卖票,ticket = 1
*/ 本程序将判断是否有票以及卖票的两个操作都统一放到了同步代码块中,这样当某一个线程操作时,其他线程无法进入到方法中进行操作,从而实现了线程的同步操作。
同步方法:利用 synchronized 定义的方法。
1 | public class SynchronizedMethodThread implements Runnable{ |
此时利用同步方法同样解决了同步操作的问题。但是在此处需要说明一个问题:加入同步后明显比不加入同步慢许多,所以同步代码性能会很低,但是数据的安全性会高,或者可以成为线程安全性高。
同步和异步有什么区别,在什么情况下分别使用它们?举例说明
如果一块数据要在多个线程间进行共享。例如:正在写的数据以后可能被另一个线程读到,或者正好在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。
abstract 的 method 是否可以同时是 static,是否可以同时是 native,是否可以同时是 synchronized?
method、static、native、synchronized 都不能和 “abstract” 同时声明方法
当一个线程进入一个对象的 synchronized 方法后,其他线程是否可以访问此对象的其他方法?
不能访问,一个对象操作一个 synchronized 方法只能由一个线程访问。
死锁
同步就是指一个线程要等待另外一个线程执行完毕才会继续执行的一个操作形式,虽然在一个程序中,使用同步可以保证资源共享操作的正确性,但是过多同步也会产生问题。多个线程抢占一个资源,导致线程死锁。
死锁就是指两个线程都在等待彼此先完成,造成程序的停滞状态,一般程序的死锁都是在程序运行时出现。
请解释多个线程访问同一资源时需要考虑到那些情况?有可能带来哪些问题?
多个线程访问同一资源时,考虑到数据操作的安全性问题,一定要使用同步操作。同步操作由以下两种操作模式:
1
2
3
4
5 // 同步代码块
synchronized(锁定对象) {代码}
// 同步方法
public synchronized 返回值 方法名称() {代码}过多的同步操作有可能会带来死锁问题,导致程序进入停滞状态。
生产者与消费者案例
在开发中线程的运行状态并不固定,所以只能利用线程的名字以及当前执行的线程对象来进行区分。但是多个线程间也有可能会出现数据交互的情况。
问题引出
在生产者和消费者模型中,生产者不断生产,消费者不断取走生产者生产的产品。
从上图中可以看出,生产者生产出信息后将其放到一个区域中,然后消费这从此区域中取出数据,但是在本程序中因为牵涉线程运行的不确定性,所以会存在以下两个问题。
- 假设生产者线程向数据存储空间添加信息的名称,还没有加入该信息的内容,程序就切换到了消费者线程,消费者线程将把该信息的名称和上一个信息的内容联系到一起。
- 生产者放了若干次的数据,消费者才开始取数据,或者是消费者取完一个数据后,还没等到生产者放入新的数据,又重复取出已取过的数据。
1 | // Message.java |
1 | // Producer.java |
1 | // Consumer.java |
1 | // Test测试类 |
通过本程序的运行结果可以发现两个严重的问题:设置数据错位:数据会重复取出和重复设置。
解决数据错乱问题
数据错位完全是因为非同步的操作,所以应该使用同步处理。因为取出和设置是两个不同的操作,所以要想进行同步控制,就需要将其定义在一个类里面完成。
1 | // 解决数据错乱问题。 |
生产者和消费者同理需要修改
1 | // Producer.java |
1 | // Consumer.java |
启动测试类
1 | 张三-->你好,张三 |
本程序利用同步方法解决了数据的错位问题,但是同时也可以发现,重复取出与重复设置的问题更加严重了。
解决数据重复问题
要想解决数据洪福的问题,需要等待及唤醒机制,而这一机制的实现只能依靠Object类完成,在Object类中定义了 3 个方法完成线程的操作。
方法 | 类型 | 描述 |
---|---|---|
public final void wait() throws InterruptedException | 普通 | 线程的等待 |
public final void notify() | 普通 | 唤醒第一个等待线程 |
public final void notifyAll() | 普通 | 唤醒全部等待线程 |
一个线程可以为其设置等待状态,但是对于唤醒的操作却有两个:notify()、notifyAll()。一般来说,所有等待的线程会按照顺序进行排列。如果使用了 notify() 方法,则会唤醒第一个等待的线程执行;如果使用了 notifyAll() 方法,则会唤醒所有的等待线程。哪个线程的优先级高,哪个线程就有可能先执行。
如果想让生产者不重复生产,消费者不重复取走,则可以增加一个标志位,假设标志位为 Boolean 类型变量。如果标志位的内容为 true ,则表示可以生产,但是不能取走,如果此时线程执行到了,消费者线程则应该等待;如果标志位的内容为 false,则表示可以取走,但是不能生产,如果生产者线程运行,则应该等待。
要想完成解决数据重复的功能,直接修改 Message 类即可。在 Message 类中加入标志位,并通过判断标志位完成等待与唤醒的操作。
1 | // Message.java |
线程的终止
通过设置标志位的方式停止一个线程的运行
1 | public class StopThread implements Runnable{ |