初识多线程

初识多线程 认识线程(Thread)线程就是一个执行流每个线程之间都可以按照顺序执行自己的代码多个线程之间同时执行着多份代码为什么要有线程单核CPU的发展遇到了瓶颈要想提高算力就需要多核CPU而并发编程能更充分利用多核CPU资源。有些任务场景需要等待磁盘IO在这等待的时间能够让CPU去做一些其他的事情也需要用到并发编程。虽然多进程也能实现并发编程但是线程比进程更加轻量进程是工厂线程是工人1.进程是包含线程的每个进程至少有一个线程存在即主线程2.进程与进程之间不共享内存空间同一个进程的线程之间共享同一个内存空间3.进程是系统分配资源的最小单位线程是CPU调度和执行的最小单位。线程共享所属进程的系统资源也就是说对于线程来说只是第一个线程创建的时候和进程一起申请资源后续再创建线程不涉及资源申请操作。同理运行过程中销毁某个线程也不会释放资源。4.一个进程挂了一般不影响其他进程但是一个线程挂了可能把同进程的其他线程一同带走Java线程线程是操作系统中的概念操作系统内核实现了线程这样的机制并且对用户成提供了一些API供用户使用。但是操作系统提供的原生线程API是C语言的而且不同操作系统提供的API还不一样所以Java对其进行了统一的封装——Thread类创建线程1. 继承Thread类2. 实现Runnable接口方法一创建一个继承于Thread方法的类并重写run方法class MyThread extends Thread{ public void run() { System.out.println(haha); } }在主函数创建MyThread实例并调用start()方法Thread tnew MyThread(); t.start();方法二创建一个实现Runnable接口的类并实现run()方法class MyRunnable implements Runnable{ Override public void run() { System.out.println(aha); } }在主函数中创建Thread实例将Runnable对象作为Thread构造方法的参数调用start()方法Runnable rnew MyRunnable(); Thread tnew Thread(r); t.start();实际生产环境中一般将方法二配合线程池来使用因为一个类只能继承一个父类但可以实现多个接口。方法二还可将任务与任务执行器解耦一个Runnable实例可以传给不同的Thread实例其他方法使用匿名内部类创建Runnable子类对象Thread tnew Thread(){ public void run(){ System.out.println(haha); } }; Thread t3 new Thread(() - System.out.println(使用匿名类创建 Thread 子类对 象));多线程在一些场景下可以提高程序的整体运行效率Thread类及常见方法构造方法Thread的几个常见属性1.ID是线程的唯一标识不同线程不能重复2.名称是各种调试工具用到的3. 状态表示线程当前所处的状态4.优先级高的理论上更容易被调度到但也仅仅是理论上因为Java的线程调度最终是交由操作系统来决定的。你设置的Java优先级只是给操作系统一个建议操作系统完全可以不听。在实际中永远不要依赖调整线程优先级来控制业务逻辑的先后顺序5.关于后台进程只需记住一句话JVM会在一个进程中的所有非后台线程用户线程结束后才会结束这个线程编写代码中程序员创建的线程默认是用户线程。也就是说后台线程存在的意义就是服务其他线程。6.是否存活是否调用了start()run()方法还没结束启动一个线程(start)创建一个Thread对象相当于招募一个工人覆写run()方法相当于告诉这个工人具体的工作。此时调用start()就相当于给这个工人安排工位让他开始干活。也就是说Java虚拟机这时才会向底层操作系统申请真正的线程资源底层是调用系统API在Java中线程对象本质上是一次性消耗品也就是说一个线程对象只能start()一次。如果再次调用start()会直接报错(IllegalThreadStateException 非法线程状态异常,只能是重新new 一个Thread如果调用start()之前直接调用run()方法。那么这和一个普通的Java方法调用没有区别此时Java虚拟机并不会向操作系统申请线程资源还是调用run()的线程来完成逻辑。休眠当前线程因为线程的调度是不可控的所以这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的休眠时间CPU的调度时间上下文切换时间。所以永远不要用sleep()来做高精度的计时器sleep(0)一种特殊的写法指在让线程立刻放弃CPU资源等待系统重新调度终止一个线程方法一通过共享标记来结束方法二调用interrupt()方法方法一定义了一个成员变量isFinished通过修改这个成员变量来控制线程的运行public volatile static boolean isFinishedfalse; public static void main(String[] args) throws InterruptedException { Thread tnew Thread(){ public void run(){ while(!isFinished){ System.out.println(haha); } } }; t.start(); Thread.sleep(3000); isFinishedtrue;volatile加一个强制刷新指令子线程每次循环都必须去主内存读取最新的值在Lambda里面使用外面的变量会触发“变量捕获”这样的语法。因为Lambda是回调函数调用的时机不确定有可能调用函数的时候外面的变量已经销毁了为了避免这种问题Java的做法就是将这个变量拷贝一份到Lambda表达式中因为这个局部变量是拷贝过来的所以在外部对其进行修改并不会同步到Lambda表达式中。所以这个变量不允许被修改一般由final修饰。如果将外部的局部变量改为成员变量那么就不是“变量捕获”的语法而是切换成“内部类访问外部类成员”的语法成员变量的生命周期是由GC管理的Lambda表达式不需要担心变量的生命周期失效的问题也就不需要进行拷贝。此时在外部就可以对成员变量进行修改修改结果也会同步到Lambda表达式中这种方法有个缺陷当子线程在执行Thread.sleep()时并不会对while条件进行判断也就是说此时修改isFinished并不会结束这个进程方法二使用Thread.interrupted()或者Thread.currentThread().isInterrupted()代替共享标记。Thread内部包含了一个boolean类型的变量作为线程是否被中断的标志public void interrupt()用于通知线程结束如果这个线程没有被阻塞那么调用Interrupt方法就会修改isInterrupted方法内部的标志位为true。如果这个线程正在被阻塞那么会唤醒sleep这种提前唤醒的情况下sleep会将isInterrupted的标志位重新设为false并抛出InterruptedException异常。具体要不要结束线程取决于catch中的代码可以选择结束线程也可选择忽略继续执行后续的逻辑。class MyThread extends Thread{ public void run(){ while(!Thread.interrupted()){ System.out.println(haha); try { Thread.sleep(1000); } catch (InterruptedException e) { //线程被动唤醒后的逻辑 } } } }public boolean isInterrupted()返回对象关联的线程的标志位调用后不重设标志位public static boolean interrupted()判断当前线程的中断标志位是否设置返回结果后将标志位重设为false等待一个线程(join)有时我们需要等待一个线程完成他的工作后才能进行下一步工作获取当前线程对象的引用线程的状态1. NEW(新建)仅仅在堆内存中new一个Thread对象但尚未调用start()方法操作系统底层并未分配真实的线程资源2. RUNNABLE(可运行)已经调用了start()方法。Java虚拟机的RUNNABLE状态涵盖了操作系统层面的两种状态就绪(Ready)和运行中(Running)。此状态的线程的线程可能正在CPU上执行代码也可能正在就绪队列中等待操作系统调度器分配CPU时间片3. BLOCKED(阻塞)线程试图获取一个内部的对象锁但该锁正在被其他线程持有4. WAITING(无限期等待)线程主动调用了某些方法进入无限期等待状态直到被其他线程唤醒5. TIMED_WAITING(限期等待)与WAITING类似但是增加了一个计时器6. TERMINATED(终止)线程的run()方法正常执行完毕或者因抛出未捕获的异常而异常退出线程安全问题public static int count0; public static void main(String[] args) throws InterruptedException { Thread t1new Thread(()-{ for(int i0;i20000;i){ count; } }); Thread t2new Thread(()-{ for(int i0;i20000;i){ count; } }); t1.start(); t2.start(); t1.join(); t2.join(); }这段代码执行结束看起来count会变成40000。但是结果却是count不仅不会变成40000连输出的结果都是一个小于40000的随机值count看似是一行代码实际上对应到CPU上是三段指令1. 将count的值加载到寄存器中LOAD2. 将count的值加一并存回寄存器ADD3. 将寄存器中的值存回内存STORE可是在并发执行的环境下这三步可能会被中断并执行别的线程1.假设此时count 的值为100t1线程将100存入寄存器中2.此时t1的时间片耗尽发生上下文切换t2开始执行。t2完整执行123步将count100从内存中取出加一变成101并将101存回内存。3.上下文切换t1重新获得CPU资源。但是此时t1只会将寄存器中的100加一得到101然后存回内存。而不是从内存中获取最新的数据这就造成了数据错误以上就是一个线程不安全的例子。如果多线程环境下代码运行的结果是符合我们预期的即在单线程环境应该得到的结果则说这个线程是安全的线程不安全的原因1.根本原因是操作系统对于线程的调度是随机的抢占式执行2. 修改共享数据3. 原子性如果修改操作只对应到一个CPU指令就可以认为是原子的CPU就不会出现一条指令执行一半的情况。但上述示例就不满足原子性因为一条代码对应了三条CPU指令一个线程正在对变量进行操作如果这个操作被打断了结果就有可能是错误的。这点也和线程的抢占式稠密程度密切相关如果线程不是抢占式的就算不满足原子性也问题不大4. 内存可见性private static int flag0; public static void main(String[] args){ Thread tnew Thread(()-{ while(flag0){ ; } System.out.println(t线程结束); }); Thread pnew Thread(()-{ Scanner scnew Scanner(System.in); flagsc.nextInt(); System.out.println(p线程结束); }); t.start(); p.start(); }当p线程运行之后用户从控制台输入1将flag的值修改为1并不会使循环退出。很明显这是一个bug一个线程读取另一个线程修改修改线程修改的值并没有被读线程读取到JMM规定所有变量都存储在主内存中每个线程拥有自己的工作内存(CPU高速缓存和寄存器)。当线程t启动时将flag0从主内存拷贝到自己的工作内存中。即使线程p将用户修改过的新值刷新回主内存由于代码中没有任何同步机制JMM规范不强制要求线程t去主内存重新读取最新值。这段代码在编译器运行一段时间后JIT编译器会介入。出于极限性能优化的目的会认为flag在循环期间绝对不会改变单线程视角下确实会如此。因此JIT会将这段机器码的逻辑改写为if (flag 0) { while (true) { // 死循环连寄存器都不读了彻底将 flag 判断剥离出循环体 } }5. 指令重排序public static SingletonLazy getInstance(){ //第一次检查如果已经实例化直接返回避免性能损耗 if(instancenull){ //只有第一次实例化才会进来 synchronized(SingletonLazy.class){ //判断是否需要new 对象防止多个对象同时通过第一条if造成重复实例化 if(instancenull){ instance new SingletonLazy();//实例化对象 } } } return instance; }为了压榨性能JIT和CPU会在不改变单线程最终执行结果的前提下打乱机器指令的执行顺序。比如说对象的实例化在底层分为三步1 分配内存空间2初始化对象调用构造方法3 将内存地址赋值给引用变量如果不加volatileCPU可能将顺序重排为1-3-2。1线程A执行完13还没来得及执行2此时线程A的CPU时间片耗尽发生上下文切换。2线程B正巧调用getInstance()经过第一个if发现instance已经不是null误以为已经实例化完成。可是此时还没有初始化线程B拿到的只是一个半成品。死锁构成死锁的场景情况一但是由于Java中synchronized和ReentrantLock的可重入性这种情况并不会在Java中形成死锁情况二但是如果我们实现两个线程两把锁第一个线程在第一把锁的内部拿第二个线程的锁第二个线程同理那么就会出现死锁。Thread t1new Thread(()-{ synchronized(lock1){ Thread.sleep(1000);//这里为了简化sleep并未被try-catch包裹 //需要等待线程1拿到锁1线程2拿到锁2再加第二把锁 synchronized(lock2){ count; } } }); Thread t2new Thread(()-{ for(int i0;i200000;i){ synchronized(lock2){ Thread.sleep(1000); synchronized(lock1){ count; } } } });情况三n个线程n把锁哲学家问题一个圆桌上有n个哲学家(线程)和n支筷子(锁)每支筷子放在两位哲学家的中间圆桌中间有一碗面。约定拿到两双筷子才能吃面(嵌套锁)吃完面才能把筷子放下。此时如果n位哲学家同时拿左边的筷子那么每位哲学家都只能拿一根筷子没有人能够吃面也没人放下筷子。这就形成了死锁。使用JVM 自带的jconsole或jvisualvm工具可检测死锁构成死锁的四个必要条件1. 锁是互斥的一个线程拿到锁之后另一个线程再尝试获取此锁必须要阻塞等待2. 锁是不可抢占的线程1先拿到锁1线程2不能从线程1中抢锁只能等到线程1主动释放锁3. 请求和保持资源请求者在请求其他资源时不放下手中的资源4. 循环等待A等待BB等待CC等待A上述四个条件都满足时便构成死锁如何破解死锁锁是互斥的和锁不可抢占是锁的两大特性不能违背。想要破除死锁只能从请求\保持和循环等待下手请求和保持避免使用嵌套锁循环等待将锁进行编号并规定锁获取的次序如获取编号较小的锁。从一号哲学家开始第一次只能拿小编号的筷子。最后七号哲学家就会有两双筷子。synchronized关键字 - 监视器锁monitor lock既然线程不安全其中的一个原因是不满足原子性那么我们就可以想办法将不是原子的操作打包成一个原子的操作这就是加锁。synchronized花括号包裹的代码也称为同步代码块synchronized的特性互斥synchronized会起到互斥的效果某个线程执行到某个对象的synchronized中时其他线程如果也执行到同一个对象的synchronized就会发生阻塞等待。进入synchronized修饰的代码块相当于加锁退出为解锁synchronized用的锁时存在Java对象里面的每个对象在内存中存储的时候都存在一块内存表示当前的锁定状态。针对每一把锁操作系统内部都维护了一个等待队列当这个锁被某个线程占有的时候其他线程尝试加锁就加不上就会发生阻塞等待。直到之前的线程解锁之后由操作系统唤醒一个新线程再来获取到这个锁。注意1.上一个线程解锁之后下一个线程不是立刻就能获取到锁而是要靠操作系统来唤醒1JVM通知操作系统内核现在这个锁空出来了2操作系统内核查找正在等待该锁的线程列表将其中一个从“阻塞”修改为“就绪”3线程进入就绪队列等待CPU时间片的下一次轮转2. 假设有ABC三个线程A先获取到锁B、C依次尝试获取锁。此时BC都会在阻塞队列中等待当A释放锁JVM会根据特定的算法不一定绝对公平从等待队列中挑一个线程来获得这把锁注意锁只能控制互斥边界但绝不能控制CPU的物理调度。也就说对于一段代码对应多个CPU指令的情况即使加锁也无法保证CPU可以一口气执行完。只要当前线程的CPU时间片耗尽就会发生上下文切换。只不过这个线程此时仍手握着这把锁直到执行完同步代码块锁才释放可重入synchronized同步块对同一条线程来说是可重入的不会出现把死锁(自己锁死)的问题实现原理1. 在第一次加锁时锁内部记录是哪个线程持有的锁后续加锁时进行判定 2. 锁内部包含一个计数器每加一次锁计数器加一每释放一个锁计数器减一。计数器减为0时才真正释放锁synchronized使用示例锁的本质是对象而不是代码块synchronized(对象){ // 进入代码块相当于加锁对应字节码指令monitorenter //代码块 } // 退出代码块相当于解锁对应字节码指令monitorexit1.括号里填的是用来加锁的对象在Java中任何一个对象都可以用作锁。这个对象的类型不重要重要的是是否有多个线程尝试针对同一个对象进行加锁是否在竞争同一把锁。因为多个线程针对同一个对象加锁才会产生互斥效果。2. JVM保证每一个monitorenter都有一个对应的monitorexit。即使代码中间抛出异常JVM也会自动加上monitorexit。方式一修饰代码块明确指定要锁的对象//锁任意对象 Object locknew Object(); Thread t1new Thread(()-{ synchronized(lock){ count; } }); //锁当前对象 class Computer{ private int num0; public void Add(){ synchronized(this){ num; } } }//锁的是Computer实例化时候的对象方法二1修饰普通方法相当于锁类所实例化的对象(this)不同实例之间不会互斥2修饰静态方法相当于锁类对象该类的所有实例在调用此方法时会竞争同一把锁Java标准库中的线程安全类vector、HashTable、ConcurrentHashMap、StringBuffer、String(没有加锁但不涉及修改)StringBuffer的核心方法中都带有synchronized使用了线程安全类也未被绝对安全因为线程安全类仅保证了单个方法线程安全实际中我们会执行很多复合操作。对于复合操作依旧应该手动加锁来保证整体逻辑的原子性虽然vector、HashTable、StringBuffer有synchronized但是代码中一旦加了锁意味着代码可能因为锁的竞争产生线程阻塞导致线程从CPU上调度走而且调度回来的时间不确定。实战中更推荐使用ConcurrentHashMap因为其内部有很多优化策略。volatile关键字保证可见性volatile修饰的变量能够保证“内存可见性”。上文提高过JMM所有的变量都存储在主内存中每个线程都有自己的工作内存。如果修改volatile修饰的值会触发一个硬件指令。这个指令不仅会将新值刷新回主内存还强制让所有CPU核心里缓存的该变量的副本瞬间失效。其他线程下次再去读取这个变量时发现自己的缓存失效了就会被迫从主内存中拉取最新的值。注意voaltile并不能保证原子性有序性加上volatile之后JIT编译器会在汇编指令层面在修改volatile变量指令的前后各插入一道内存屏障1. 写在指令前面禁止上面的普通写和下面的volatile写重排序2. 写在指令后面防止后面可能出现的读操作与volatile写发生重排wait和notify由于线程之间是抢占式执行的因此线程之间执行的先后顺序难以预知。但是实际开发中我们希望可以灵活协调多个线程之间的执行先后顺序。wait() \ wait(long timeout)让当前线程进入等待状态notify() \ notifyAll()唤醒当前对象上等待的线程注意wait、notify、notifyAll都是Object类的方法waitsynchronized(lock){ //代码逻辑,加锁状态 try { lock.wait();//解锁开始阻塞等待 } catch (InterruptedException e) { throw new RuntimeException(e); } //wait()唤醒之后重新获取锁执行后续逻辑 }wait做的事情1.线程释放锁清空_Owner状态由RUNNABLE变为WAITING进入Monitor的等待集合(_WaitSet)中休息。此时它不参与锁的竞争2.满足以下唤醒条件时唤醒该线程1其他线程调用该对象的notify方法2wait等待时间超时3其他线程调用该线程的interrupted方法导致wait抛出InterruptedException异常notify即JVM从_WaitSet中挑出一个线程这个线程被唤醒之后并不是立即执行。它的状态会由WAITING变为BLOCKED并转移到阻塞队列中。等到执行notify的线程释放锁之后和其他线程一起竞争锁。示例输入数字之后开始唤醒t1线程Object locknew Object(); Thread t1new Thread(()-{ try { synchronized(lock){ System.out.println(wait之前); lock.wait(); System.out.println(wait之后); } } catch (InterruptedException e) { throw new RuntimeException(e); } }); Thread t2new Thread(()-{ synchronized(lock){ Scanner scnew Scanner(System.in); sc.nextInt(); lock.notify(); } });注意如果有多个线程同时等待那么使用notifyAll()会同时唤醒多个线程但具体哪个线程抢到锁那不一定。避免虚假唤醒总结一句话wait()绝不能被if条件语句包裹应该使用while循环语句线程被notify唤醒的瞬间到他真正重新执行业务代码之间存在一个不受控制的锁竞争和时间差举个例子有一个盘子(锁盘子里放个包子。有两个消费者A和B看到盘子空了就等待有包子就吃掉。生产者P负责放包子synchronized (plate) { if (plate.isEmpty()) { plate.wait(); // 盘子空了交出锁睡觉 } plate.remove(0); // 醒来后直接吃包子 }1.初始状态下盘子是空的此时消费者A获得锁发现盘子是空的释放锁线程等待。2.生产者P获得锁放一个包子然后调用notifyAll()释放锁3.此时消费者A进入阻塞队列和其他消费者一同竞争锁。4.消费者B这时也来争抢锁锁首先被消费者B获得消费者B看到盘子里有包子将其吃掉盘子变空。B执行完毕释放锁。5.此时消费者A获得锁由于A 使用的是if语句不会重新判断条件而是直接选择吃包子。造成程序异常更改过后使用while语句只要不满足条件就会再次进入线程等待synchronized (plate) { while (plate.isEmpty()) { plate.wait(); // 盘子空了交出锁睡觉 } plate.remove(0); // 醒来后直接吃包子 }wait和sleep的区别wait用于线程之间的通信sleep用于线程阻塞控制执行时机1.wait会释放锁sleep不会2.wait需要搭配synchronized使用sleep不需要3.wait是Object的方法sleep是Thread的静态方法对比进程和线程线程的优点1. 创建一个新线程的代价以及比创建一个新进程小得多2.与进程之间的切换相比线程之间的切换需要操作系统做的工作要少很多3. 线程占用的资源比进程少很多4.能充分利用多核处理器的并行数量5.等待慢速I/O操作结束的同时程序可以执行其他任务6. 计算密集型应用为了能在多核处理器系统上运行将计算分解到多个线程中实现I/O密集型应用为了提高性能将I/O操作重叠。线程可以同时等待不同的I/O操作进程与线程的区别1.进程是系统分配资源的最小单位线程是CPU调度和执行的最小单位2.进程有自己的内存地址空间线程只独享指令流执行的必要资源如寄存器和栈3.由于同一进程的各线程间共享内存和文件资源可以不通过内核进行直接通信4.线程的创建、切换以及终止效率更高