Java笔记(9):多线程
多线程
多线程的创建
方式一:继承于Thread类
- 创建一个继承于Thread类的子类
- 重写Thread类的run()方法(将线程执行的操作写在里面)
- 创建Thread类子类的对象
- 通过此对象调用start()方法(不能通过直接调用run()方法启动线程)
1 | public class Demo { |
当我们需要两个线程同时进行的时候,我们不可以再启动一个已经start的线程的对象,需要新建一个对象。
我们还可以通过创建Thread类的匿名子类实现多线程创建:
1 | new Thread(){ |
方式二:实现Runnable接口
- 创建一个实现了Runnable接口的类
- 实现run()方法
- 创建实现类的对象
- 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
- 通过Thread类的对象调用start()方法
1 | public class Demo { |
由于实现类的对象与启动线程的对象不相同,因此一个实现类对象可以有多个线程。
开发中我们一般优先选择方式二实现Runnable接口的方式:
- 实现没有类的单继承性的局限性
- 实现的方法更适合来处理多个线程有共享数据的情况
二者之间有所联系: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的返回值。
使用过程:
- 创建一个实现Callable的实现类
- 实现Call()方法,类似于run()
- 创建Callable实现类的对象
- 将Callable实现类的对象传递到FutureTask构造器中
- 创建Thread对象,并执行start()方法启动线程
- 若需要返回值,则可以使用Callable实现类的对象的get()方法返回值
1 | public class Demo { |
方式四:线程池
当经常需要创建和销毁、使用量比较大的资源,比如并发情况下的线程,对性能影响较大。这时候我们可以提前创建好多个线程,放入线程池中,使用时直接获取,使用完后放入池中。使用线程池有几点好处:
- 提高响应速度
- 降低资源消耗
- 便于线程管理
Java标准库提供了ExecutorService接口表示线程池,但ExecutorService只是一个接口,Java标准库提供的几个常用实现类:
- FixedThreadPool:线程数固定的线程池
- CachedThreadPool:线程数根据任务动态调整的线程池
- SingleThreadExecutor:仅单线程执行的线程池
创建这些线程池的方法都被封装在Executors类中,均为newXXXX
。
线程池多线程使用流程:
- 创建线程池
- 执行指定的线程的操作,提供一个Runnable接口(execute()方法)或Callable接口(submit()方法)实现类的对象作为参数
- 关闭线程池
例子以FixedThreadPool线程池为例:
1 | public class Demo { |
设置线程池属性
由于ExecutorService作为一个接口,里面不包含具体的方法,我们通过getClass()方法可以获取到service的类是ThreadPoolExecutor,我们前面是使用多态的方式创建的对象,导致ThreadPoolExecutor的方法不能使用,因此我们可以使用强转的方式转换为ThreadPoolExecutor类,再调用设置线程池属性的方法:
1 | ExecutorService service = Executors.newFixedThreadPool(10); |
线程的调度
Java对于同优先级线程组成先进先出队列(先到先服务),采用“时间片”策略(切换处理),对于高优先级,使用有限调度的抢占式策略(高优先级线程抢占CPU)。
线程的优先级有三个常量:
- MAX_PRIORITY : 10
- MIN_PRIORITY : 1
- NORM_PRIORITY : 5
涉及的方法:
- getPriority() : 返回线程优先值
- setPriority(int newPriority) : 改变线程的优先级
注意点:
高优先级线程抢占低线程的执行权,但是不意味需要当高优先级执行完以后,低优先级的线程才能执行,只是从概率上讲,高优先级的线程更优先被执行。
线程创建时会自动继承父线程的优先级。
线程的生命周期
线程的启动到线程的结束的整个过程被称之为线程的生命周期。
线程的生命周期中存在多种状态:
- 新建:当一个Thread类及其子类被声明并创建时,新生的线程处于就绪状态
- 就绪:当新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已经具备了运行的条件,但暂时没有分配到CPU资源
- 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态
- 阻塞:在某些情况下,被人为挂起或者执行输入输出操作时,临时中止自己的执行,进入阻塞状态
- 死亡:线程完成了全部工作,或者线程被强制提前终止,或出现异常导致结束
线程的安全性问题
当我们多个线程同时去对同一个参数进行操作时,我们往往会发现,这个参数出现多个线程同时操作时出现只操作一次的情况,举个售票的例子:
1 | public class Demo { |
我们执行如上代码,在控制台,我们发现一个严重的问题,窗口1、2、3出现了售出同一张票的情况:
这种就是线程的安全问题。解决方式就是当线程A在操作的时候,其他线程无法参与,直到线程A操作完成后,其他线程才可以开始操作,即使线程A出现阻塞也无法参与。
同步机制解决线程安全问题
方式一:同步代码块
结构如下:
1 | synchronized(同步监视器){ |
共享数据:多个线程共同操作的变量,比如:ticket;
同步监视器:俗称锁。任何一个类的对象都可以充当锁,但要求多个线程必须共用同一个锁。
例1:
我们将上面卖票窗口的代码修改为
1 | class ticketWindows implements Runnable{ |
我们再去检查控制台,发现没有再次出现重票错票的情况了。
但是当我们通过继承Thread的方式去创建多线程的时候,我们就发现了一个问题——他仍然出现了错票重票的情况。我们前面提到锁必须多个线程共用一个,而当我们通过继承Thread的方式创建多线程的时候,我们使用的锁this
指代的对象并不是指代的同一个,而分别是t1, t2, t3
,因此锁就失效了,因此我们作出对应的修改
1 | while(true) { |
通过反射的知识,我们可以直接将ticketWindows
作为一个对象,充当了锁,因为t1, t2, t3
内的ticketWindows
都是共用的一个对象,因此,我们就没有出现线程的安全性问题了。
注意,我们用synchronized代码块包含代码时,我们仅需将操作共享数据的代码包起来即可,不能包多,包多可能会与实际情况相违背了:
1 | synchronized (ticketWindows.class) { |
例如这种情况,我们就会出现一个线程在操作,其他线程全部没有使用的情况。
方式二:同步方法
在方法定义出加上synchronized关键词可以将方法变成同步方法
1 | private synchronized void test(){} |
同步方法同样是存在锁的,它的锁是this
,因此对于继承Thread
方法创建的多线程,直接如上使用的话一样是存在线程的安全性问题的。我们可以通过将方法写为静态的方式,解决这个问题。
1 | private static synchronized void test(){} |
由于静态方法里面不能使用this
,所以它的锁自然而然的就变成类ticketWindows.class
。
总结
使用同步机制,我们解决线程的安全问题,但操作同步代码时,只能由一个线程参与,其他线程只能等待,相当于是一个单线程的过程,效率不太高。
懒汉式单例改写为线程安全式
在多线程调用懒汉式的getInstance
方法时,如果我们不做保证线程安全性问题的措施,我们有可能会出现多个线程同时进入了if语句内,导致创建了多个实例,不能实现我们单例设计模式的目的。因此我们需要通过上面所学的知识,解决线程安全性问题,首先是同步方法和同步代码块的方式解决:
1 | public static synchronized Bank getInstance(){ |
但者两种方式效率并不是很高,表面上是多线程执行,但实际上只能同时进行一个操作,与之前所提到的例子不同,单例设计模式线程安全性问题仅会出现在不存在实例的时候,因此我们可以在外面再包多一次if语句判断是否已经存在实例,这样就只会在实例不存在的时候,进入一次同步代码块,这样效率就高很多了:
1 | public static Bank getInstance(){ |
线程的死锁问题
死锁:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放出自己需要的同步资源,就形成了线程的死锁。出现死锁后,不会出现异常,不会出现提示,但所有的线程都会处于阻塞状态,无法继续执行。
我们构造两个线程,第一个线程先握住s1锁,再握住s2锁,第二个线程先握住s2锁,再握住s1锁:
1 | public class Demo { |
我们发现,控制台它什么都没有显示,什么都没有提示:
这是因为线程1抢到了s1锁,然后线程2抢到了s2锁,但是线程1又需要s2锁才能继续执行,而线程2有需要s1锁才能继续执行,然后二者就僵持住了,导致了这个结果。
解决方式:
- 专门的算法、原则
- 尽量减少同步资料的定义
- 尽量减少嵌套同步
Lock锁解决线程安全问题
Lock锁是JDK5.0新增的更强大的线程同步机制,通过显式定义同步锁对象来实现同步,同步锁使用Lock对象充当。
使用的步骤:
- 定义一个ReentrantLock类的对象,构造器内可以选择是否启用公平机制,如果填入true的话,Lock锁就会按先来后到的顺序安排线程,而不是抢占式,默认不填构造器即false
- 使用try-finally环绕包含执行操作过程,并在操作过程前调用锁定方法lock()
- 在finally内调用解锁方法unlock()
1 | class ticketWindows implements Runnable{ |
synchronized与Lock锁差别
- Lock锁是手动锁定与解锁,是显式锁,而synchronized机制是相应代码执行完后自动解锁线程
- Lock只有代码块锁,synchronized有代码锁和方法锁
- Lock锁由于比较后出现,优化较好,JVM将使用较少时间来调度线程,性能更好,而且提供了更多子类
- 优先使用顺序:Lock ——> 同步代码块 ——> 同步方法
线程的通信
涉及到的方法:
- wait():执行该方法会使当前线程进入阻塞状态,并释放锁
- notify():执行此方法会唤醒的被wait的线程,如果多个线程都被wait,则优先唤醒优先度高的线程
- notifyAll():执行此方法会唤醒全部被wait的线程
例:使用两个线程交替售票
1 | class ticketWindows implements Runnable{ |
整体思路就是通过wait()阻塞线程1,然后让线程2拿到锁再去唤醒线程1,执行完线程2操作后线程2进入阻塞,以此循环。
注意点
- wait(),notify(),notifyAll()三个方法必须使用在同步代码块或同步方法中
- wait(),notify(),notifyAll()三个方法调用者必须是同步代码块或同步方法的同步监视器,否则会出现异常
- wait(),notify(),notifyAll()三个方法式定义在java.lang.Object类中
sleep()与wait()异同
- 同:都会进入阻塞状态
- sleep()定义在Thread类中,wait()定义在Object类中
- 调用的范围:sleep()可以在任何情景下调用,wait()必须在同步代码块和同步方法中
- sleep()不会释放锁,wait()会释放锁