十六、线程
- 并发和并行:并发—单核cpu同一时间多个任务交替执行 并行—多核cpu多个任务同时执行
- 线程启动只能使用start()方法,不能直接run()
- 创建线程的三种方法:
- 继承Thread类,重写run方法,实现自己的业务代码(Thread实现Runnable接口)(Thread01.java)
- 实现Runnable接口,重写run方法(Runnable01.java)
- 实现callable接口,重写call方法(Callable01.java)
->callable的好处:
Runnable遇到run方法中出现的异常只能用try-catch,但是callable遇到call方法中的异常可以直接throw;
callable允许执行的任务返回一个结果。
线程两种实现方法的关系:(Runnable02.java)
Thread类实现了Runnable接口,而Runnable中含有start()、start0()--真正实现多线程的效果、run()三个方法,三个方法从左到右以次嵌套调用。当使用A类继承Thread类时,实例化的A对象a就可以当成一个线程来使用,启动时需要a.start(),因为Thread类重写了接口的start()方法,在start()类里面又调用(Thread的)start0()方法(调用start0()的实际上是JVM),执行start0()后线程并不一定马上执行,只是将线程变成了可运行状态,具体什么时候执行,取决于CPU,由它统一调度,之后start0()里面又调用(A类的)run()方法,因此最终来执行线程启动的任务交给继承了Thread类的类A的run()方法。因为动态绑定,因此在此过程中都应以A的身份来调用方法,只是因为A没有实现start(),start0(),所以会去调用父类的这两个方法,但是A有run(),因此最终的run()由A类来执行。(有关动态绑定这一部分的逻辑:DynamicLogic.java)
- 多线程机制:
程序启动之后main线程就会跟着启动,main线程会在main函数执行完毕之后终止,main函数中要是有其他线程启动操作并不会影响主线程继续向下执行,并行过程;
在主线程开启另外一个子线程,只要主线程没有结束,子线程和主线程就是交替执行的;
子线程也可以开启其他子线程
- 使用Runnable创建线程的原因:
- java是单继承的,若是一个类已经继承了一个普通类,又想继承线程就不被允许了
- 多个线程想要使用同一个资源,实现同一段run的业务逻辑也需要使用Runnable(Threads.java)解释:Runnable->启动多个线程对象Thread thread1,2,3,4,5..... = new Thread(T1); thread1,2,3,.start();因为传入的是同一个实现了线程接口Runnable的类对象,因此run()方法的调用都是一样的。Thread->通过new继承了Thread的类对象来启动线程,当你使用继承 Thread 类的方式创建多个自定义线程类对象时,每个对象都会拥有自己的资源,并且它们之间不能直接访问其他对象的资源。每个线程对象都有自己的状态和行为,它们之间是相互独立的。
- 线程终止:当线程执行完毕会自动终止,也可以通过使用变量来控制run方法退出的方式停止线程,即通知方式(ThreadExit.java)
- 线程中断:interrupt,中断线程并没有真正结束线程,所以一般用于中断正在休眠的线程(InterruptThread.java)
->捕获线程中断使用异常处理的InterruptedException
- 线程插队:
- yield:线程礼让,正在占用cpu的线程让出cpu,但是具体让不让得出取决于当前资源环境,要是cpu觉得其实可以并发处理过来,就不会切换线程执行
- Join:直接插队到当前正在执行的线程前面优先执行,原本正执行的线程处于等待或者超时等待过程
- 用户线程:又称工作线程,当前线程任务执行完或者通知方式结束
- 守护线程:一般为工作线程服务,当所有用户线程结束,守护线程自动结束(常见的守护线程:垃圾回收机制)(DaemonThread.java)
- 线程的状态:
- NEW 尚未启动的线程
- BLOCKED 当线程因为某些原因被阻塞而暂时无法继续执行时,它处于阻塞状态。常见的原因包括等待锁的释放、等待输入/输出等。一旦阻塞的条件消失,线程将会重新进入可运行状态。
- RUNNABLE(可运行状态): 当线程调用了 start() 方法后,线程进入可运行状态。处于可运行状态的线程可能正在执行(RUNNING),也可能正在等待系统分配CPU时间片(READY)。
- WAITING(等待状态): 当线程因为某些原因需要等待其他线程的通知或者等待一段时间时,它处于等待状态。处于等待状态的线程会一直等待,直到接收到通知或者等待超时。
- TIMED_WAITING(计时等待状态) 处于计时等待状态的线程会等待一段时间,如果在等待期间没有接收到通知或者其他线程中断它,那么超时之后线程会自动返回到可运行状态。
- TERMINATED(终止状态): 线程执行完成或者因为异常而结束时,它处于终止状态。一旦线程终止,它将不再处于任何其他状态。
- 线程同步机制:Synchronized
售票问题中,正是因为线程没有同步处理,导致票数可以超卖。在多线程问题中,一些敏感数据不允许被多个线程同时访问,此时就使用同步访问技术,保证数据在任何时刻,最多有一个线程访问,以保证数据的完整性
->具体方法:
- 同步代码块:
方法一:
sychronized(同一对象){//得到对象的锁,才能同步代码块
//需要同步的代码块
}
方法二:
public sychronized void m(String name){
//需要同步的代码块
}
->解决售票问题(SolveSellTickets.java)
- 互斥锁
->同步方法如果没有使用static修饰:默认锁对象是this
->同步方法使用static修饰,那么默认锁对象是:当前类.class
->不管是静态方法还是普通,在给代码块加互斥锁时,互斥锁可以是一个相同的对象,选择一个专门用于同步的对象作为锁会更安全,避免了因为使用了不可预测的对象而导致意外的竞争情况或者死锁问题。(Mutex.java)
14、线程死锁:指的是两个或多个线程互相持有对方所需的资源,并且在等待对方释放资源的同时,又不释放自己持有的资源,导致所有线程都无法继续执行,处于阻塞状态,从而陷入无法解除的僵局。(DeadLock.java)例子中有关于static属性的一个案例,简单来说:static和类绑定,因此类加载第一次的时候,系统就会在内存中为static类型的属性分配一个空间,
每创建一个类对象时,都会保留一个指向static属性的指针
单例模式,指令重排,volatile
补充:指令重排序在多线程运行的隐患:
intra-thread semantics:单线程内部的语义和顺序。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。即intra-thread semantics 允许在单线程内部不会引起程序执行结果的重排序。例子:现有一段代码,实现将实例化的类对象赋值给instance变量的操作instance = new Singleton();在单线程内部具体实现这段代码主要分成三部分:
一、分配内存空间:首先,Java 虚拟机会在堆内存中分配一块足够大的内存空间,用于存储对象的数据。
二、初始化对象:然后,Java 虚拟机会调用对象的构造函数来初始化对象的状态,即对对象的成员变量进行赋初值、执行构造函数中的初始化代码等。
- 将对象引用赋值给变量:最后,Java 虚拟机将对象的引用地址赋值给指定的变量,使得我们可以通过该变量来访问对象。
- 不属于该代码的执行过程,但是在该代码执行完毕之后,就能进行对象的访问,具体就是通过变量指向的内存来访问对象。
intra-thread semantics机制会允许步骤二和三互换位置,但是会保证二在四之前执行,对于单线程来说,二三互换位置并不会造成影响。但在多线程中,可能存在当一个线程A先执行三步骤时,另外一个线程开始访问变量instance是否为空,这时变量已经在线程A中实现了指向一块内存的操作,只是内存中还没有东西,但是instance已经不为null了,这样就会产生一个instance还没有完全初始化结束(即执行完一二三步骤),另外一个访问该instance的线程就会抢先得出instance!=null的判断。这就是指令重排给多线程带来的麻烦。
解决指令重排的方法:
禁止指令重排序(顺序性)----->volatile关键字
volatile的其他作用:可见性 多线程有自己的缓存区,但是使用volatile声明的变量的写和读取都直接在主存中操作,即一旦一个线程对变量进行操作修改了,就会立即将修改的新值刷新到主存供其他线程共享。因此多线程在对该变量的共享上可以做到正确和实时性。
单例模式(单线程):
- 饿汉式:类加载的时候实例化类对象(new 类对象),在类中创建一个实例化的类对象,并且将其设置为静态私有,由于静态属性和类绑定,因此在类加载的时候就会创建一个静态实例化对象,以后再实例化对象的时候就都指向第一个实例化的类对象,实现单例。
- 懒汉式:在类加载的时候没有直接实例化(将它指向空或者不写等号右边),但是还将承接类对象的变量设置为静态私有的,再创一个公共实例方法,在调用指定实例方法的时候再进行实例化,这样就保证不使用的时候也不会实例化(EagerLazySignal.java)
->对比:
线程安全:饿汉再线程还没出现之前就已经实例化了,所以饿汉一定是线程安全的。懒汉是在使用时才会去new实例,如果这个时候有多个线程首次访问这个实例,都去调用getInstance函数,但是都判断instance==null,那么所有线程都会创建实例,导致出现错误,解决该问题可以考虑使用sychronized,加同步锁
执行效率:饿汉没有任何锁,因此执行效率更快,懒汉加了同步锁,会导致阻塞
内存使用:饿汉在类首次加载的时候就会开辟一块内存空间来存放实例对象,但是懒汉只有在用到该实例对象的时候才会实例化。
继续分析懒汉模式(多线程)->虽然将方法设置为同步方法会使线程安全,但是同步锁会使线程阻塞,每一个线程都得在阻塞之后才能进入同步方法来判断对象有无实例化,因此改进方法,出现了双重检查锁定机制
分析:多个线程第一次调用getIntance方法时,都会通过5的判断,阻塞在同步方法之前, 其中一个线程获取到同步锁,进入同步方法,通过7判断,创建第一个实例对象,而后退出;下一个通过5判断的线程进入同步方法,此时因为第一个线程已经实例化了instance,因此同步过7判断,保证了实例对象的唯一。之所以使用volatile,原因在指令重排序在多线程运行的隐患中已经做过解释,多线程首次调用getInstance时,其中一个通过5和6,来到8,但是8实际操作分为三步,若没有使用volatile就会使三步操作中初始化和赋值(就是将变量指向一个内存,使变量的值不为null,但是实际该内存中并没有东西)颠倒顺序,这时,如果有另一个线程走到5,就会认为instance不为null,直接return,可能会导致程序出现未定义的错误,比如一个线程利用该什么也没有的变量执行其他操作,就会得到错误的结果。至此,单例模式,指令重排,volatile全部介绍完毕。
15、锁
1)自旋锁(SpinLock.java):当某线程尝试获取的同步资源被其他线程占用,那么使用自旋锁技术的线程就不会放弃cpu的时间片,通过自旋等待锁释放,当其他线程将锁释放成功,该线程就能去竞争锁。非自旋锁会使其他线程没有获取到资源的时候直接挂起,让线程休眠,CPU就会利用这段时间做些其他的事情,直到占有资源的线程结束,释放了锁,其他线程就会被cpu唤醒,让其他线程再次尝试去获取非自旋锁,再失败,再休眠,cpu再去干其他事情,循环往复。
->优点:自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销
2)可重入锁:一个线程可以重复多次的获取到同一把锁,但是不会发生死锁。
->手动上锁,手动释放锁,一个线程下可以释放与上锁次数不一样,但是实际上不安全;多线程下,上几次锁就得对应的释放几次锁,这样其他线程才能获取到该锁。(ReentrantLockExample02.java)
->可重入锁的等待:Condition c = new lock.newCondition(); c.await();
->可重入锁的通知:c.notify(); c.notifyAll()
- 虚假唤醒:虚假唤醒通常与判断条件的写法有关。如果在等待之前只是使用了if语句来检查条件,那么在虚假唤醒的情况下,线程可能会错误地认为条件已经满足,从而在没有收到真正的通知时就继续执行。改成while循环即可。
- 自定义类显式地继承Thread--->在类内重写run方法--->new 自定义类--->自定义类对象.start()
自定义类--->方法A()实现本应该写在run方法里面的内容--->创建一个自定义类对象a--->lambda表达式new Thread(()->{里面写a.A()})
(ReentrantLockExample01.java)