多线程
什么是线程.
1)线程是轻量化的进程,而进程是包含线程的,每个进程最少要包含一个线程.当系统给进程分配资源的时候会经历三个阶段
1.创建进程2.销毁进程3.调度进程.这三个过程都非常消耗时间,而如果在程序之间如果有共享的资源这时候重新调度就会非常浪费(举个我刚刚看到的例子:在我写这篇文档的时候,WPS会自动的帮我保存文档,而键盘会读取我输入的文字,屏幕负责显示文字,在单核心CPU上(类似于并发)如果只有一个进程,那么就只能做一件事.所以只能采用多进程的方式,而每个进程都会有我文档的信息,进程的来回切换很浪费(进程内的上下文比线程多)同时可以减少通信带来的性能损失(访问共享空间))于是就有了线程.
二.线程和进程
- 进程是资源分配的基本单位,而线程是调度的基本单位,我的理解是进程是一个村委会,上面负责发经费,而线程是一个又一个的工作目标(比如保存,显示,键盘输入),需要啥问进程要就是了,自己出个“人”(寄存器,程序计数器,栈之类的)。
- 每个线程有自己的执行逻辑(执行流)。且线程之间共享同一块内存地址。
- 多线程
多线程的优势是比单线程执行的更快,缺陷是容易产生线程安全问题(下面再说)
1.并发
多个线程来回切换执行
- 并行
多个线程在不同CPU核心上执行
四,Java中多线程编程
- 在Java中进行多线程编程采用的是Thread这个类,他是Java.lang下的包。
使用Thread有五种方法
- 继承Tread类并重写run方法
- 实现Runnable接口并重写run方法
- 使用匿名内部类,继承Tread类并重写run方法
- 使用匿名内部类,实现Runnable接口并重写run方法
- 采用lambda表达式.
- 其中run方法表示的是任务,是线程开辟后要做的事,也可以不用多线程把他当普通方法来使用。而t.start(),才是线程真正的开辟。Runnable和继承Thread并没有什么区别,只是让任务看起来更明确的分开。
- 多线程的五种构造方法
- Thread();
- Thread(Runnable target);使用Runnable对象创建线程
- Thread(String name);为线程命名,默认是Thread - 0可以在调试里看到
- Thread(Runnable target,String name);同上
- Thread(ThreadGroup group,Runnable target);给线程分组
- 多线程的常见属性
1)ID getID();
在Thread中这个ID与在线程中默认的名字Thread-N的N没有任何关系它是由另一个属性来决定的(ThreadInitNumber),而这个ID是唯一标识符(ThreadSeqNumber)。
2)名称 getName();
默认的名字是Thread-N,我们可以指定名字
3)状态 getState();
1.NEW(初始状态)一切准备就绪但是还没分配PCB,就是在start()之前
2.TERMINATIED 线程执行完毕但是还没有销毁,运行被打断,终止都会变成这个状态
3.RUNNABLE 线程正在执行或者准备执行,当然是在执行了start()的前提下
阻塞状态
4.WAITING 线程等待其他线程把他唤醒 join();?
5.TIMED_WAITING 无需等待其他线程唤醒可以自己醒过来,比如sleep();
6.BLOCKED 没有什么休眠之类的,单纯的要等待到他运行的时机
4)优先级 getPriority();
理论上优先级高的更容易被调用,然并卵
5)是否后台线程 isDaemon();
后台线程(守护线程),在关闭进程的时候,需要等待前台线程运行完毕,而不需要等待后台线程(正牌男友要提分手,备胎则无所谓);
6)是否存活 isAlive();
线程开始之后和结束之前
- 是否中断 isInterruped();
判断线程是否要中断,是的话返回true否则返回false;
但是否中断看代码怎么写的.
5.常见的方法
1)线程的等待 join();
Public void join();
Public void join(long millis);最多等millis毫秒
Public void join(long millis,int nanos);方法等待至多为millis毫秒+毫微秒纳米秒该线程终止。
当两个线程一起开始我们又想让线程A等待线程B执行完后再执行那么可以用到join()方法.假如我们再主线程里开辟了一个线程,正常情况下两个线程互不干扰,谁先结束也不知道,但是在main线程里可以使用t.join();来使main线程在等待t线程结束后再执行;
- 获取当前线程的引用 currentThread();
在刚才那个线程终止也提到过 public static Thread currentThread();返回当前对象的引用,在那个线程调用的方法就是哪个线程.
- 线程的休眠 sleep();
有两个重载的sleep,public static void sleep(long millis, int nanos)和public static void sleep(long millis),前者精度更高,但是这两个方法都要处理InterruptedException异常.
- 中断线程
中断线程有三种方法
1.通过改变标志位来结束run方法的执行,run结束了线程也就结束了
- 使用stop();这个方法已经被弃用了可以看这篇文档
Java Thread Primitive Deprecation (oracle.com)
- 使用isInterrupt()配合Thread.currentThread().isInterrupted();isInterrupt可以改变线程中的标志位为true,而Thread.currentThread().isInterrupted()可以检测线程中的这个返回值。但是线程的终止都是以通知的形式,是否终止还是要看代码怎么写的,如果代码就检测一下或者根本没管那么就算通知一百遍也没啥用。
Synchronized 关键字-监视器锁monitor lock
synchronized可以给一个方法和代码块进行加锁,当一个线程进入到synchronized修饰的方法或代码块中时,就对这个对象进行了加锁操作,其中静态方法是类对象,普通方法是引用这个方法的对象,而代码块可以指定对象.那么加锁操作又是怎么理解的呢?
当一个线程进入synchronized修饰的方法或代码块时就对这个对象进行了加锁,其他线程在调用时就不能调用这个对象了,每个线程对方法加锁就是让对应的锁的计数器自增只有是零的时候才能让新的线程对这个对象的这把锁自增也就是上锁,必须得阻塞等待线程一执行完毕.当多个线程同时竞争的情况下那么就要靠”抢”了
图一是加了synchronized图二没加可以看到在图一中t2在等待t1线程结束后才执行add方法.
什么是死锁?
- 死锁是指一个或多个线程由于加锁时的思虑不周导致线程之间循环等待无法进行下一步.
死锁的三种典型情况
- 不可重入锁
同一个线程多次对同一个对象加锁,而解锁的代码在下一段代码中导致没有办法解锁。
那么当同一个线程第二次进入被加锁的对象的同一个方法时会不会出现死锁(线程循环等待)呢?
如果这是不可重入锁那么在t1线程通过fun进入fun时就会阻塞等待第一层fun执行完毕不会进入第二个fun所以
答案是不会的,因为synchronized是可重入锁,同一个线程对同一个对象相同的锁可以进入多次,就像上面说的计数器会多自增一次.
两个线程抢两把锁
可以看到,当他t1给A加锁后t2也给B加锁在他们里面又互相获取对方的锁这个时候就卡住了。
多个线程抢多把锁(第二种情况的一般情况)
和上面类似。只是更复杂。
- 死锁的四个必要条件
- 互斥使用 线程1拿到了锁线程2就要等着
- 不可抢占 当线程1拿到了锁,除非他主动放弃使用否则其他线程是拿不到这把锁的
- 请求和保持 当线程1拿到了锁A,当他再请求拿到锁B时,锁A还是他的(保持).
- 循环等待 一开始线程1获得锁A,线程2获得了锁B,当线程1又想获得锁B而且线程2也想获得锁A那么在一开始各自获得的锁没有释放的时候他们就会循环等待对方执行完毕
4.如何解决死锁问题
1)哲学家吃面条
给每个锁编一个号码,再给线程定下一个使用锁的规矩,只能拿编号小的锁,就像想吃面条的哲学家只能拿编号小的筷子一样.
2)银行家算法(以后再来讨论)
当没有产生锁竞争也就是对一个对象使用不同的加锁方法是会不会产生阻塞等待呢?
答案是没有.也是对象中的锁的不同的原因.
Volatile关键字
用于解决内存可见性问题和指令重排序,当一个线程循环使用一个变量而另一个线程会修改这个变量,那么这个时候第一个线程就会因为编译器/JVM优化而导致使用的是修改之前的变量值.这是因为在循环读取时编译器/JVM错误的判断了我们的逻辑,使第一个线程并没有循环读取而是使用之前的,
但是这个优化也不一定全是错的当我们修改代码让循环慢一点时就没有判断错误
最后让我们给变量count前加上volatile关键字
可以看到当我们加上volatile以后就算删掉sleep也没有一直循环等待,这个关键字就是告诉编译器我要改变这个值不用优化了,不过这样也会带来性能的降低.对了这个关键字是不能给方法里加的,因为方法里的是局部变量并没有其他人可以用.
wait和notify(等待和通知)
wait和notify都是Object类的方法在使用时可以通过Object调用,由于Object是所以类的父类这也代表着只要没有重写那么这个类就可以调用这两个方法。
![]() |
wait的作用是让线程进入阻塞状态,总共分为三步1.使当前代码进去阻塞状态2.释放当前锁3.被唤醒后重新尝试获取这个锁。这里提到了锁那么我们在让线程等待时是否要对线程加锁呢,答案是肯定的,可以看到但我们对t1进行wait操作时我们要处理这个InterruptedException异常(大多数阻塞等待都是通过这个异常被唤醒的),这个异常并不会因为notify而抛出而是由其他主动终止线程的方法来抛出比如interrupt。那么如果不加锁使用呢?可以看到但不加锁使用时就抛出了这个异常。
当我们用notify唤醒wait后,wait会尝试重新加锁,当我们用一个线程在他之前抢占了这把锁是不是就无法唤醒
答案是,是的。当我们这个卡住wait的线程解锁后wait又可以重新获取这把锁
wait方法有两个版本带参数和不带参数,其中带参数的方法是等待参数时间后自动
![]() |
唤醒自己。
第二个唤醒wait的方法时notify,在t1.start()下加sleep是为了让t1的wait先执行不然虽然notify先执行没有语法错误,但是会导致t1没有线程去唤醒他。
这个时候我们可以发出一个问题,如果有多个wait,notify会唤醒哪一个呢?答案是随机唤醒一个幸运儿。
而想要唤醒指定线程可以通过不同的对象加锁来达到目的
notify还有一个“亲戚”notifyAll,这个亲戚可以唤醒全部的线程,虽然唤醒了全部的但是执行还是要靠抢的顺序。
第三种,在等待线程里加上interruped();方法进行判断等待其他线程使用interrupt方法导致wait抛出InterruptedException异常。这种操作是为了方便在我们主动打断线程的时候可以让线程进行一下自己想要的操作.
单例模式
所谓单例模式就是只能实例化一个实例的模式,书上说有23种实际上科技在进步,模式也越来越多。在这里先介绍两种。
- 懒汉模式
懒汉模式的特点就是使用时才创建对象。就像懒人是不会提前做准备的
- 饿汉模式
饿汉模式是提前创建好对象,至于这个比喻我理解不来
在代码里可以看到饿汉模式只涉及到读,而懒汉模式涉及到了读和写,那么在多线程操作里饿汉模式相对要安全许多,而懒汉模式就会产生线程安全问题。那么这些问题分为几个方面呢
- 非原子性
由于懒汉模式中,存在load,cmp,new(这里看作一个操作不影响结论)等操作,那么在多个线程同时使用load,和比较的时候会出现都是null的情况同时重新new一个对象给singleLazy2.我们可以通过加锁来解决。
- 效率问题
当我们给代码加锁以后就会在每次使用getSingleLazy2时都会有阻塞等待这种事而实际上我们只需要判断一次就好,此时我们可以给锁外面再来一个if判断一下,注意:这里有两个同样的判断,但目的并不同,最外层的时判断加不加锁,里面的是判断要不要new一个对象
- 指令重排序
首先我们可以把new对象分为三个步骤1.申请内存空间2.把内存空间初始化为一个对象3.把对象赋给singleLazy2引用,正常情况下时123来执行但是有时候编译器有时候会变成132,当执行完3的时候线程被切换走了由于我们是给代码块加的锁,那么当另一个线程执行到最外层判断的时候就不会进入synchronized中这个时候线程获得了一个并没有初始化为一个对象也就是非法的对象,此时我们的解决方法是加上volatile关键字就可以解决,因为volatile除了可以解决内存可见性问题也可以解决指令重排序问题。
阻塞队列(BlockingQueue)
阻塞队列其实就是一个另类的队列,在队列满时如果还想入队列就要阻塞等待,等待一个元素出队列,在队列空时要想出队列就要阻塞等待,等待一个元素入队列。虽然比较特殊但他仍然是一个队列,遵循队列的先入先出规则。
生产者消费者模型
这个模型主要是为了解决强耦合,缓冲问题,比如一个客户端向服务器A发送数据,然后A发送给B。此时如果想把B换成C,就要把A中的许多代码更改这时如果采用一个阻塞队列程序既可以做到解耦合,当A与B之间有了一个中间件,就可以使用中间件提供的接口来进行交互,当把B换成C后也无需改变A,只要C使用中间件的接口就好、也可以让客户端在发送大量数据时让服务器可以一步一步的处理不至于处理不过来崩溃掉。削峰填谷就很好的表示了阻塞对了在任务过多和过少的时候能保持一个相对平稳的速度。
在java标准库中也提供了阻塞队列,这里就先说三种,一种是用链表实现的LinkedBlockingQueue,一种是数组实现的ArrayBlockingQueue,一种是用优先级队列(堆)实现的PriorityBlockingQUeue。阻塞队列具有普通队列的三个方法(poll,offer,peek),还有两与之对应的方法put,take这两个方法要抛异常,毕竟两个方法都有可能使线程产生阻塞等待。当我们进行等待时应该使用循环来反复确认是否相等
定时器
标准库中的定时器
Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { System.out.println("hello"); } }, 3000); |
自己写的定时器
public class Test { |
可以看到在输出的地址前有一个$1这就代表我们输出了内部类的地址
线程池
随着科技的飞速发展,线程的创建和销毁也逐渐的在计算机中占用了大部分资源,此时我们有两种方法去提高这部分的效率,
- 这时就像线程对于进程的出现而出现了一种协程/纤程(很遗憾目前java并不支持协程,Go语言倒是可以),
- 采用线程池(像个备胎池,呼之即来挥之即去,用完就回池子里了)来降低开销.
从池子里获取线程,放回线程都要比操作系统内核创建和销毁更轻量,啥是操作系统内核,当我们调用一些资源时要通过代码去与操作系统”沟通”然而操作系统不是为我们一个程序服务的他手里还有其他程序(内核态),当我们去告诉他的时候他可能会先执行其它代码再来执行我们的代码,当我们提前申请好了资源,这时我们使用申请好的资源就会更方便可控一些(用户态).
在java中标准库为我们提供了现成的线程池
ExecutorService pool = Executors.newFixedThreadPool(10); |
我们可以看到这个线程池与我们平常构造一个对象有所不同,并没有使用new关键字,这是因为他采用了一种工厂模式来编写代码,工厂模式就是不提供公开构造方法,采用静态方法间接调用构造一个对象(这样的方法是工厂方法,提供这样方法的类是工厂类). 至于为啥会有工厂模式,原因是构造方法中构造不同情况的对象通过重载,而如果不同情况下参数类型是相同的那么就无法构成重载,这时通过不同静态方法(方法可以有很多不同的名字,构造方法只能有一个)来构造对象就会方便许多.
采用submit方法来给线程池添加任务,任务并不是严格平均分配给线程的只是差不多平均.这里我用了一个n来接收变量i的值再给run用,这是因为在Java里存在一个变量捕获机制,当定义run时就把n的拷贝在run的栈上,等运行时就定义一个n变量值为捕获的.这主要是因为生命周期的关系,run方法并不会立刻执行而是在后续的某个时机执行,好呢有空可能当执行时i已经被销毁了.
ExecutorService pool = Executors.newFixedThreadPool(10); |
当我执行完任务时进程并没有结束,因为线程池里的线程都是前台线程会阻止进程结束.
工厂类
1.指定线程池有几个线程
2.指定线程数量后,指定创建使用的工厂模式
Executors.newFixedThreadPool(10, new ThreadFactory() { |
- 动态的创建线程
- 动态的创建线程数量后,指定创建使用的工厂模式
- 线程池里只有一个线程
- 让任务延时执行
上述这些方法本质上都是通过包装ThreadPoolExecutor类实现的,为啥?因为原本的太复杂了,不多说直接看.
我们看一下参数最多的
- 核心线程数
- 最大线程数
- 除核心线程外的线程最多不执行任务的时间
- 时间的单位
- 任务队列
- 创建线程的工厂方法
- 拒绝策略,当线程满了后应该如何对这个任务进行处理是不执行还是执行
有四种
- 任务太多直接抛出异常
- 如果队列满了谁加入的谁执行
- 如果队列满了就舍弃最早的任务
- 不管这个任务,当没加入过
代码实现
import java.util.concurrent.BlockingQueue; |
Over接下来就是多线程进阶部分