多线程

多线程的创建

方式一:继承于Thread类

  1. 创建一个继承于Thread类的子类
  2. 重写Thread类的run()方法(将线程执行的操作写在里面)
  3. 创建Thread类子类的对象
  4. 通过此对象调用start()方法(不能通过直接调用run()方法启动线程)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Demo {
public static void main(String[] args) {
TestThread testThread = new TestThread();
testThread.start();
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}

class TestThread extends Thread{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}

当我们需要两个线程同时进行的时候,我们不可以再启动一个已经start的线程的对象,需要新建一个对象。

我们还可以通过创建Thread类的匿名子类实现多线程创建:

1
2
3
4
5
6
7
8
9
10
new Thread(){
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}.start();

方式二:实现Runnable接口

  1. 创建一个实现了Runnable接口的类
  2. 实现run()方法
  3. 创建实现类的对象
  4. 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
  5. 通过Thread类的对象调用start()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo {
public static void main(String[] args) {
new Thread(new TestThread()).start();
}
}

class TestThread implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}

由于实现类的对象与启动线程的对象不相同,因此一个实现类对象可以有多个线程。

开发中我们一般优先选择方式二实现Runnable接口的方式:

  1. 实现没有类的单继承性的局限性
  2. 实现的方法更适合来处理多个线程有共享数据的情况

二者之间有所联系:Thread类本身也是实现Runnable接口的

Thread类常用方法

void start():启动线程,并执行run()方法

run():线程被调度时执行的操作

String getName():返回线程的名字

void setName():设置线程名字

static Thread currentThread():返回当前线程,相当于this

yiele():释放当前线程的cpu执行权

join():优先执行该线程,原线程进入阻塞状态,执行完该线程后再继续执行原线程

stop():强制结束当前线程生命期(不推荐使用

sleep(long millisec):让当前执行的线程休眠指定millisec毫秒(进入阻塞状态)。使用该方法时,会抛出一个异常,由于父类run()方法是没有抛出异常的,因此只能使用try-catch处理异常

isAlive():判断线程是否存活

方式三:实现Callable接口

这种创建多线程的方式是JDK5.0新增的一种方式。与实现Runnable相比,Callable功能更强大:

  • 相比run()方法,call()方法可以有返回值
  • call()方法可以抛出异常
  • 支持泛型的返回值
  • 需要借助FutureTask类

FutureTask是Future接口的唯一的实现类,它同时实现了Runnable和Future接口。它既可以作为Runnable被线程执行,也可以作为Future得到Callable的返回值。

使用过程:

  1. 创建一个实现Callable的实现类
  2. 实现Call()方法,类似于run()
  3. 创建Callable实现类的对象
  4. 将Callable实现类的对象传递到FutureTask构造器中
  5. 创建Thread对象,并执行start()方法启动线程
  6. 若需要返回值,则可以使用Callable实现类的对象的get()方法返回值
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
public class Demo {
public static void main(String[] args) {
//3.创建Callable实现类的对象
NumThread numThread = new NumThread();
//4.将Callable实现类的对象传递到FutureTask构造器中
FutureTask futureTask = new FutureTask(numThread);
new Thread(futureTask).start();
try {
//get()返回值即为FutureTask构造器参数的Callable实现类重写的call()方法的返回值
Object sum = futureTask.get();
System.out.println("sum = " + sum);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
//1.创建一个实现Callable的实现类
class NumThread implements Callable{
@Override
//2.实现Call()方法
public Object call() throws Exception {
int sum = 0;
for(int i = 0; i < 100; i++) {
if(i % 2 == 0) {
System.out.println(i);
sum += i;
}
}
return sum;
}
}

方式四:线程池

当经常需要创建和销毁、使用量比较大的资源,比如并发情况下的线程,对性能影响较大。这时候我们可以提前创建好多个线程,放入线程池中,使用时直接获取,使用完后放入池中。使用线程池有几点好处:

  • 提高响应速度
  • 降低资源消耗
  • 便于线程管理

Java标准库提供了ExecutorService接口表示线程池,但ExecutorService只是一个接口,Java标准库提供的几个常用实现类:

  • FixedThreadPool:线程数固定的线程池
  • CachedThreadPool:线程数根据任务动态调整的线程池
  • SingleThreadExecutor:仅单线程执行的线程池

创建这些线程池的方法都被封装在Executors类中,均为newXXXX

线程池多线程使用流程:

  1. 创建线程池
  2. 执行指定的线程的操作,提供一个Runnable接口(execute()方法)或Callable接口(submit()方法)实现类的对象作为参数
  3. 关闭线程池

例子以FixedThreadPool线程池为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Demo {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
service.execute(new TestThread());
for (int i = 0; i < 100; i++) {
if(i % 2 != 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
service.shutdown();
}
}
class TestThread implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}

设置线程池属性

由于ExecutorService作为一个接口,里面不包含具体的方法,我们通过getClass()方法可以获取到service的类是ThreadPoolExecutor,我们前面是使用多态的方式创建的对象,导致ThreadPoolExecutor的方法不能使用,因此我们可以使用强转的方式转换为ThreadPoolExecutor类,再调用设置线程池属性的方法:

1
2
3
ExecutorService service = Executors.newFixedThreadPool(10);
ThreadPoolExecutor service2 = (ThreadPoolExecutor) service;
service2.setCorePoolSize(10);//设置核心池大小为15

线程的调度

Java对于同优先级线程组成先进先出队列(先到先服务),采用“时间片”策略(切换处理),对于高优先级,使用有限调度的抢占式策略(高优先级线程抢占CPU)。

线程的优先级有三个常量:

  • MAX_PRIORITY : 10
  • MIN_PRIORITY : 1
  • NORM_PRIORITY : 5

涉及的方法:

  • getPriority() : 返回线程优先值
  • setPriority(int newPriority) : 改变线程的优先级

注意点:

高优先级线程抢占低线程的执行权,但是不意味需要当高优先级执行完以后,低优先级的线程才能执行,只是从概率上讲,高优先级的线程更优先被执行。

线程创建时会自动继承父线程的优先级。

线程的生命周期

线程的启动到线程的结束的整个过程被称之为线程的生命周期。

线程的生命周期中存在多种状态:

  1. 新建:当一个Thread类及其子类被声明并创建时,新生的线程处于就绪状态
  2. 就绪:当新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已经具备了运行的条件,但暂时没有分配到CPU资源
  3. 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态
  4. 阻塞:在某些情况下,被人为挂起或者执行输入输出操作时,临时中止自己的执行,进入阻塞状态
  5. 死亡:线程完成了全部工作,或者线程被强制提前终止,或出现异常导致结束

image-20220520171701160

线程的安全性问题

当我们多个线程同时去对同一个参数进行操作时,我们往往会发现,这个参数出现多个线程同时操作时出现只操作一次的情况,举个售票的例子:

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
public class Demo {
public static void main(String[] args) {
ticketWindows ticketWindows = new ticketWindows();
Thread t1 = new Thread(ticketWindows);
Thread t2 = new Thread(ticketWindows);
Thread t3 = new Thread(ticketWindows);
t1.setName("Window1");
t2.setName("Window2");
t3.setName("Window3");
t1.start();
t2.start();
t3.start();
}
}

class ticketWindows implements Runnable{
private int ticket = 100;
@Override
public void run() {
while(true) {
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + " sell:" + ticket);
ticket--;
}else break;
}
}
}

我们执行如上代码,在控制台,我们发现一个严重的问题,窗口1、2、3出现了售出同一张票的情况:

image-20220521173909064

这种就是线程的安全问题。解决方式就是当线程A在操作的时候,其他线程无法参与,直到线程A操作完成后,其他线程才可以开始操作,即使线程A出现阻塞也无法参与。

同步机制解决线程安全问题

方式一:同步代码块

结构如下:

1
2
3
synchronized(同步监视器){
//需要被同步的代码:操作共享数据的代码。
}

共享数据:多个线程共同操作的变量,比如:ticket;

同步监视器:俗称锁。任何一个类的对象都可以充当锁,但要求多个线程必须共用同一个锁。

例1:

我们将上面卖票窗口的代码修改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ticketWindows implements Runnable{
private int ticket = 100;
@Override
public void run() {
synchronized (this) {
while(true) {
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + " sell:" + ticket);
ticket--;
}else break;
}
}
}
}

我们再去检查控制台,发现没有再次出现重票错票的情况了。

但是当我们通过继承Thread的方式去创建多线程的时候,我们就发现了一个问题——他仍然出现了错票重票的情况。我们前面提到锁必须多个线程共用一个,而当我们通过继承Thread的方式创建多线程的时候,我们使用的锁this指代的对象并不是指代的同一个,而分别是t1, t2, t3,因此锁就失效了,因此我们作出对应的修改

1
2
3
4
5
6
7
8
while(true) {
synchronized (ticketWindows.class) {
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + " sell:" + ticket);
ticket--;
}else break;
}
}

通过反射的知识,我们可以直接将ticketWindows作为一个对象,充当了锁,因为t1, t2, t3内的ticketWindows都是共用的一个对象,因此,我们就没有出现线程的安全性问题了。

注意,我们用synchronized代码块包含代码时,我们仅需将操作共享数据的代码包起来即可,不能包多,包多可能会与实际情况相违背了:

1
2
3
4
5
6
7
8
synchronized (ticketWindows.class) {
while(true) {
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + " sell:" + ticket);
ticket--;
}else break;
}
}

例如这种情况,我们就会出现一个线程在操作,其他线程全部没有使用的情况。

方式二:同步方法

在方法定义出加上synchronized关键词可以将方法变成同步方法

1
private synchronized void test(){}

同步方法同样是存在锁的,它的锁是this,因此对于继承Thread方法创建的多线程,直接如上使用的话一样是存在线程的安全性问题的。我们可以通过将方法写为静态的方式,解决这个问题。

1
private static synchronized void test(){}

由于静态方法里面不能使用this,所以它的锁自然而然的就变成类ticketWindows.class

总结

使用同步机制,我们解决线程的安全问题,但操作同步代码时,只能由一个线程参与,其他线程只能等待,相当于是一个单线程的过程,效率不太高。

懒汉式单例改写为线程安全式

在多线程调用懒汉式的getInstance方法时,如果我们不做保证线程安全性问题的措施,我们有可能会出现多个线程同时进入了if语句内,导致创建了多个实例,不能实现我们单例设计模式的目的。因此我们需要通过上面所学的知识,解决线程安全性问题,首先是同步方法和同步代码块的方式解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static synchronized Bank getInstance(){
if(bank == null){
bank = new Bank();
}
return bank;
}

public static Bank getInstance(){
synchronized (Bank.class) {
if(bank == null){
bank = new Bank();
}
return bank;
}
}

但者两种方式效率并不是很高,表面上是多线程执行,但实际上只能同时进行一个操作,与之前所提到的例子不同,单例设计模式线程安全性问题仅会出现在不存在实例的时候,因此我们可以在外面再包多一次if语句判断是否已经存在实例,这样就只会在实例不存在的时候,进入一次同步代码块,这样效率就高很多了:

1
2
3
4
5
6
7
8
9
10
public static Bank getInstance(){
if(bank == null){
synchronized (Bank.class) {
if(bank == null){
bank = new Bank();
}
}
}
return bank;
}

线程的死锁问题

死锁:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放出自己需要的同步资源,就形成了线程的死锁。出现死锁后,不会出现异常,不会出现提示,但所有的线程都会处于阻塞状态,无法继续执行。

我们构造两个线程,第一个线程先握住s1锁,再握住s2锁,第二个线程先握住s2锁,再握住s1锁:

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
42
43
44
45
46
47
48
49
50
public class Demo {
public static void main(String[] args) {
StringBuffer s1 = new StringBuffer();
StringBuffer s2 = new StringBuffer();

new Thread(new Runnable() {
@Override
public void run() {
synchronized (s1) {
s1.append("a");
s2.append("1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s2) {
s1.append("b");
s2.append("2");

System.out.println(s1);
System.out.println(s2);
}
}
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
synchronized (s2) {
s1.append("c");
s2.append("3");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s1) {
s1.append("d");
s2.append("4");

System.out.println(s1);
System.out.println(s2);
}
}
}
}).start();
}
}

我们发现,控制台它什么都没有显示,什么都没有提示:

image-20220523191927155

这是因为线程1抢到了s1锁,然后线程2抢到了s2锁,但是线程1又需要s2锁才能继续执行,而线程2有需要s1锁才能继续执行,然后二者就僵持住了,导致了这个结果。

解决方式:

  • 专门的算法、原则
  • 尽量减少同步资料的定义
  • 尽量减少嵌套同步

Lock锁解决线程安全问题

Lock锁是JDK5.0新增的更强大的线程同步机制,通过显式定义同步锁对象来实现同步,同步锁使用Lock对象充当。

使用的步骤:

  1. 定义一个ReentrantLock类的对象,构造器内可以选择是否启用公平机制,如果填入true的话,Lock锁就会按先来后到的顺序安排线程,而不是抢占式,默认不填构造器即false
  2. 使用try-finally环绕包含执行操作过程,并在操作过程前调用锁定方法lock()
  3. 在finally内调用解锁方法unlock()
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
class ticketWindows implements Runnable{
private int ticket = 100;
// 定义一个ReentrantLock类的对象
private ReentrantLock lock = new ReentrantLock(true);
@Override
public void run() {
while(true) {
try {
// 调用锁定方法lock()
lock.lock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + " sell:" + ticket);
ticket--;
}else break;
}finally {
// 调用解锁方法unlock()
lock.unlock();
}
}
}
}

synchronized与Lock锁差别

  • Lock锁是手动锁定与解锁,是显式锁,而synchronized机制是相应代码执行完后自动解锁线程
  • Lock只有代码块锁,synchronized有代码锁和方法锁
  • Lock锁由于比较后出现,优化较好,JVM将使用较少时间来调度线程,性能更好,而且提供了更多子类
  • 优先使用顺序:Lock ——> 同步代码块 ——> 同步方法

线程的通信

涉及到的方法:

  • wait():执行该方法会使当前线程进入阻塞状态,并释放锁
  • notify():执行此方法会唤醒的被wait的线程,如果多个线程都被wait,则优先唤醒优先度高的线程
  • notifyAll():执行此方法会唤醒全部被wait的线程

例:使用两个线程交替售票

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ticketWindows implements Runnable{
private int ticket = 100;
@Override
public void run() {
while(true) {
synchronized (this) {
notify();
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + " sell:" + ticket);
ticket--;
}else break;
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

整体思路就是通过wait()阻塞线程1,然后让线程2拿到锁再去唤醒线程1,执行完线程2操作后线程2进入阻塞,以此循环。

注意点

  1. wait(),notify(),notifyAll()三个方法必须使用在同步代码块或同步方法中
  2. wait(),notify(),notifyAll()三个方法调用者必须是同步代码块或同步方法的同步监视器,否则会出现异常
  3. wait(),notify(),notifyAll()三个方法式定义在java.lang.Object类中

sleep()与wait()异同

  • 同:都会进入阻塞状态
  • sleep()定义在Thread类中,wait()定义在Object类中
  • 调用的范围:sleep()可以在任何情景下调用,wait()必须在同步代码块和同步方法中
  • sleep()不会释放锁,wait()会释放锁