JavaEE初阶:多线程初阶

JavaEE初阶:多线程初阶 目录一.线程1.线程是什么2.多线程编程3.认识线程类Thread3.1.Thread的常用构造方法3.2.Thread常见的属性4.实战线程4.1.创建线程的五种方式4.2.线程常见属性的实例4.2.1获取线程ID4.2.2获取线程名称4.2.3获取线程状态4.2.4获取状态优先级4.2.5线程是否存活4.2.6是否后台线程4.2.7线程是否终止5.线程安全问题5.1线程不安全的原因5.1.1多线程同时修改同一个变量5.1.2修改操作不是原子的5.1.3内存可见性问题5.1.4指令重排序5.2.synchronized5.2.1互斥5.2.2可重入5.3.wait和notify5.3.1wait和notify的介绍5.3.2方法的使用6.总结一.线程“线程”的概念在上一篇文章“进程与线程的区别和联系”中有详细的讲解下面也会重新讲解重要概念。1.线程是什么线程可以想象成一个“盒子”里面实现了一堆要运行的代码这个盒子就是线程。操作系统通过cpu调用线程并执行其中的代码完成线程的使命2.多线程编程多线程编程就是“并发编程”。我们在多个线程中实现代码为什么就叫“并发编程”其实是因为有操作系统的随机调度这层背景。并发的详细定义在“进程与线程的区别和联系”.注意多线程之间可能“并行执行”也可能“并发执行”程序员不用关注是哪种执行方式我们既知道不了也干预不了。3.认识线程类Thread3.1.Thread的常用构造方法构造方法解释Thread()创建一个线程Thread(Runnable)创建一个参数只允许传入Runnable接口或实现了Runnable接口的类的线程Thread(String)创建线程并给线程起个名字Thread(Runnable,String)上面两个解释的和3.2.Thread常见的属性线程属性获取方法IDgetId()名称getName()状态getState()优先级getPriority()是否存活isAlive()是否后台线程isDaemon()是否终止了isInterrupted()每个线程独有一个IDID是线程的唯一标识。名称是各种调试工具用到。状态表示线程所处的情况。优先级高的理论上来说更容易被调用。判断线程是否正在运行run方法是否运行结束。是否是后台线程。就算后台线程还在运行非后台线程前台线程运行结束进程就结束关闭了。线程是否终止是则返回true否返回false。后面会实例线程使用线程方法并作出细节说明4.实战线程4.1.创建线程的五种方式设计好run()后使用“引用.start()”启动线程,线程启动后会自动调用run方法这个run方法就是线程要执行的任务所以run必须设计好.上述采用创建一个类继承Thread并重写run方法的方式创建线程除这种方式能创建线程外还有四种创建的方式。//方式2创建类实现Runnable接口并重写run方法然后将含有MyRunnable对象的引用作为构造参数 class MyRunnable implements Runnable{ Override public void run() { while(true){ System.out.println(hello t1); } } } public class Test2 { public static void main(String[] args) { //不能实例化接口只能实例实现了接口的类 //向上转型 Runnable runnable new MyRunnable(); Thread t1 new Thread(runnable); //线程启动后调用MyRunnable的run t1.start(); } } //---------------------------------------------------------------------------- //方式3使用“匿名内部类”的方式创建线程 public class Test3 { public static void main(String[] args) { Runnable runnable new Runnable() { Override public void run() { while(true){ System.out.println(hello t1); } } }; Thread t1 new Thread(runnable); t1.start(); } } //---------------------------------------------------------------------------- //方式4对Thread使用匿名内部类重写run并让Thread类型的引用继承匿名内部类 public class Test4 { public static void main1(String[] args) { Thread t1 new Thread(){ Override public void run() { while(true){ System.out.println(hello t1); } } }; t1.start(); } } //---------------------------------------------------------------------------- //方式5用lambda表达式 public class Test5 { public static void main(String[] args) { //lambda表达式 Thread t1 new Thread(()-{ while(true){ System.out.println(hello t1); } }); t1.start(); } }上述五种创建线程的方式中最常用的就是方式5使用lambda创建线程。4.2.线程常见属性的实例注意线 程属性是线程创建时就默认分配好的不存在只有启动线程才能获取属性的说法。4.2.1获取线程ID4.2.2获取线程名称4.2.3获取线程状态Java给线程设定了这么几种状态NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED状态说明NEW创建了线程但还未启动线程RUNNABLE正在工作或即将工作随叫随到BLOCKED由于锁导致的阻塞状态WAITING死等没有超时时间的等待TIMED_WAITING1.由设定了超时时间的阻塞2.线程阻塞由于线程没吃到cpu资源就不继续执行线程内容引起的有时间限制的阻塞TERMINATED工作完成NEW、RUNNABLEBLOCKED不了解“锁”的可以先看下面“5.2 synchronized”这一主题中锁的定义WAITING在上述代码中比较难获取到主线程的状态我们可以点开 C:\Program Files\Java\jdk-17\bin下的jconsole.exe观察我们正在运行的程序进程。第12行就是join()语句的位置。TIMED_WAITING看见join方法的使用是不是感觉很不科学假如我们在外面等一个人等了三四、四五小时人还没来我们就会不等走了。join方法也一样join还可以设定超时时间join(1000)-超时时间为1000毫秒超过这个时间没等到就接着执行下面的代码了join(1000,500)-超时时间为1000毫秒500纳秒。sleep的休眠时间单位也如此4.2.4获取状态优先级优先级高的进程或线程更容易被调用这一现象也体现在开启多个后台玩游戏。我们玩游戏可能电脑卡一下游戏就结束了但是qq等程序延迟接收信息一分钟也没关系所以游戏程序的优先级比qq高才能保障游戏吃到更多的cpu资源画面更流畅。优先级高低形式还分场景。在Linux中优先级值越小优先级别越高。Java中优先级值越大优先级别越低。4.2.5线程是否存活4.2.6是否后台线程手动创建的线程都默认为“前台线程”。前台线程和进程的结束息息相关前台线程全部结束了就算还有后台线程在运行进程也会结束。4.2.7线程是否终止看懂上述代码逻辑结构后我们不明白为什么在主线程中,手动终止t1线程会报错呢说sleep interrupt。其实是sleep()在作怪当我们手动终止终止原理将isInterrupted()中的标志位改为true线程时如果线程正在sleep休眠那么sleep就会被唤醒并抛出InterruptedException,并把isInterrupted方法中的标志位改为false,这时要是catch没处理好就会出现如下图手动终止了但是线程没终止的bug。5.线程安全问题“线程安全问题”又叫“线程不安全”通过下面一个示例了解保障线程安全的重要性。public class Test6 { public static int count 0; public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(()-{ for(int i0;i50000;i){ count; } }); Thread t2 new Thread(()-{ for(int i0;i50000;i){ count; } }); t1.start(); t2.start(); //main线程等待t1、t2线程结束 t1.join(); t2.join(); System.out.println(count count); } }按上述逻辑count最后应该等于100000才符合o预期。原因解释图下附带说明图片说明count语句在操作系统中是三步操作线程一在执行完第一步操作后cpu就被调走了线程二被调用执行了三步操作count值加1等于1然后线程一被调用由于“线程上下文”功能寄存器就从第二步开始执行因为寄存器中的值还是0所以011执行第三步将1读回内存赋给count的值为1所以进行两次count语句count还等于1。除了上述这种情况还有但不限于5.1线程不安全的原因1(根本)操作系统对线程的调度是随机的。抢占式执行。2多线程同时修改同一个变量。3修改操作不是原子的原子性。如果一条语句只对应一条CPU指令就认为是原子的那么就不会出现“一条语句执行一半”的情况4内存可见性问题。5指令重排序问题。这些都是引起线程安全问题的原因根据原因我们可以自己解决开发中的安全问题。5.1.1多线程同时修改同一个变量首先不能从原因1下手解决问题因为程序员修改不了操作系统可以从原因2入手把“并发编程”风格代码改成“串行”风格。修改如下public class Test7 { public static int count0; public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(()-{ for (int i 0; i 50000; i) { count; } }); Thread t2 new Thread(()-{ for (int i 0; i 50000; i) { count; } }); t1.start(); //等待t1线程执行完再执行后续代码。 t1.join(); //t1线程执行完执行t2线程。 t2.start(); t2.join(); System.out.println(countcount); } }这样等待一个线程结束再执行另一个线程的方式就是“串行执行”避免多个线程同时修改同一个变量出现线程安全问题。但上面的方案还不够通用第二种方案是加“锁”。方案二解决了原因3。5.1.2修改操作不是原子的方案二public class Test8 { public static int count0; public static Object object new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(()-{ synchronized(object){ for (int i 0; i 50000; i) { count; } } }); Thread t2 new Thread(()-{ synchronized(object){ for (int i 0; i 50000; i) { count; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(countcount); } }synchronized说明synchronized是一个锁synchronized()的括号中写入一个对象这个对象又叫“锁对象”锁对象可以是任何类型进入synchronized代码块就对“锁对象”上锁出代码块就解锁在多线程编程中锁对象还未解锁t2线程就被调度执行synchronized对同一个锁对象加锁这样会造成t2线程阻塞等待只有等待前面的synchronized执行完将锁对象解锁了其他线程才有机会上锁。只有锁对象相同才会产生阻塞效果。synchronized在5.2详细介绍上述代码解释t1、t2线程要执行的run()中,synchronized的锁对象是同一个所以在先执行的synchronized结束前t2线程中的synchronized是不能被执行的。因此count1000005.1.3内存可见性问题解决原因4。产生内存可见性问题的背景JVM会主动优化我们写的代码但程序逻辑不会改变被优化后的代码就可能出现”内存可见性问题“例如下面代码import java.util.Scanner; public class Test9 { public static int num0; public static void main(String[] args) { Thread t1 new Thread(()-{ while(num0){ //...... } System.out.println(t1线程结束); }); Thread t2 new Thread(()-{ Scanner sc new Scanner(System.in); System.out.println(输入num值); numsc.nextInt(); System.out.println(num变量修改完成num); }); t1.start(); t2.start(); } }运行上述代码后修改num的值为1可是程序还在运行也就是说t1线程的while循环还在继续这就让人疑惑num!0为什么while循环还不结束t1线程还不结束其实是因为t2线程被调度后等待用户输入内容的时间对于电脑CPU来说简直”沧海山田“现在普通CPU一秒的计算次数高达39亿次以上所以就算是一毫秒对电脑CPU来说也是非常久远的时间。在反复进行while循环条件判定后JVM发现这个条件一直成立后续JVM就把判断条件num0的认为恒成立所以就算后续num的值不等于0while循环也不受影响。这样变量被JVM优化后导致的线程安全问题就叫”内存可见性问题“使用volatile修饰变量可以解决内存可见性问题用volatile修饰变量就是在告诉JVM不能优化这个变量volatile只能修饰变量修改如下public volatile static int num0;5.1.4指令重排序public class Test10 { //代码背景多线程环境下 private static Test10 instance null; public static Object object new Object(); public static Test10 getInstance(){ //外层if是判断instance是否需要加锁如果没有外层if的话每次都要竞争锁会阻塞等待减低效率 if(instancenull){ synchronized(object){ //里面这个if是为了判断是否给instance new对象在并发编程下可能在外层if判 // 断为空进入外层if但由于随机调度的原因可能instance又有对象了如果进入 // synchronized不再次判断就会再次new对象 if(instancenull){ instancenew Test10(); } } } return instance; } }上述代码看似没问题但其实暗藏玄机instancenew Test10;语句涉及三步操作1申请内存2在空间上构造对象3内存空间的首地址赋值给引用变量。一般是1、2、3这样的执行顺序但也有可能1、3、2当执行完3后CPU去调度其他线程结果其他线程判断出instance不为空虽然该引用变量指向的空间没有任何东西但是它依然不等于空就直接return instance。这样因为执行指令的顺序改变而引起线程安全问题的原因就叫“指令重排序”。解决“指令重排序”的方法也很简单就是使用volatile修饰变量。volatile有两个作用1.确保每次读取操作都是都内存2.volatile修饰的变量在读取或修改不会触发重排序。5.2.synchronizedsynchronized语法synchronized(锁对象){//加锁内容}说明synchronized是一个锁锁对象可以是任意类型的对象Object类型、Student类型都行例如5.1.1.2的修改例子synchronized的锁对象就是Object类型。5.2.1互斥synchronized有互斥效果进入synchronized修饰的代码块对锁对象自动加锁退出synchronized代码块锁对象自动解锁某个线程正在执行synchronized代码块另一个或另一些线程如果也执行到同一个对象的synchronized那后面这个synchronized就会阻塞等待等待对象解锁后才有机会竞争到锁简单来说就是我和你有同一个对象我先对这个对象上锁了我的事还没做完你就算也有事你也得等我结束了才行。互斥效果在count10万前后对比明显。5.2.2可重入讲到可重入就离不开“死锁”的情况。死锁的第一种典型情况如下public class Test11 { public static Object object new Object(); public static void main(String[] args) { Thread t new Thread(()-{ synchronized(object){ synchronized(object){ System.out.println(因为synchronized是可重入锁所以没成死锁); } } }); t.start(); } }上述代码synchronized相互嵌套而且两个锁的锁对象是同一个。外层synchronized已经对object指向的对象加锁只有退出了外层代码块之后才能再次对object指向的对象加锁。那么内层synchronized也要加锁就会产生阻塞只有等待退出外层synchronized代码块才能轮到内层加锁但这就产生死循环了。理论上会“死锁”程序不会结束但这个代码真正运行后发现程序打印完后就正常结束了这是怎么回事呢原来是因为synchronized是一个可重入锁可重入锁会判断嵌套的锁的锁对象是否相同如果相同会放行还会使用一个计数器count计算套了多少层相同对象的锁出代码块count--当count0时这个可重入锁就结束了。(Java中这样嵌套可以运行当C则会真正死锁)下面就不涉及可重入知识点但也是典型的死锁方式。死锁的第二种典型情况public class Test12 { public static Object lock1 new Object(); public static Object lock2 new Object(); public static void main(String[] args) { Thread t1 new Thread(()-{ synchronized(lock1){ try { Thread.sleep(1000);//t1线程休眠1秒 synchronized(lock2){ System.out.println(t1线程结束); } } catch (InterruptedException e) { throw new RuntimeException(e); } } }); Thread t2 new Thread(()-{ synchronized(lock2){ try { Thread.sleep(1000);//t2线程休眠1秒 synchronized(lock1){ System.out.println(t2线程结束); } } catch (InterruptedException e) { throw new RuntimeException(e); } } }); //使用sleep防止某个线程直接执行完达不到实验效果 t1.start(); t2.start(); } }这种A嵌套BB嵌套A产生的死锁。5.3.wait和notify由于线程之间是抢占式执行的因此线程的先后执行顺序是难以预知的。但在开发中程序员更希望线程的执行顺序是可预知。这时使用wait和notify就能控制线程的执行顺序。5.3.1wait和notify的介绍wait和notify是Object类的两个方法都无返回值。wait()和notify()必须在synchronized内使用否则报错而且调用这两个方法的对象必须和synchronized的锁对象相同否则报错。看下面的伪代码就能明白。Thread t1 new Thread(()-{ synchronized(lock1){//对lock1对象加锁 //... lock1.wait();//wait把lock1对象解锁然后进行休眠只有等待lock1.notify()唤醒后才能接着执行后续代码wait被唤醒后会重新对lock1上锁 } ); Thread t2 new Thread(()-{ synchronized(lock1){//对lock1对象加锁 //... lock1.notify();//把相同对象的wait唤醒如果有多个同对象的wait正在休眠则随机唤醒一个 } );可以使用lock1.notifyAll()唤醒所有lock1.wait()但是全部唤醒后锁对象会重新加锁其他线程就会阻塞所以这个方法一般也不会使用的。5.3.2方法的使用public class Test13 { public static Object lock1 new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(()-{ synchronized(lock1){ System.out.println(执行wait之前); try { lock1.wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println(执行wait之后); } }); Thread t2 new Thread(()-{ synchronized(lock1){ System.out.println(执行notify之前); lock1.notify(); System.out.println(执行notify之后); } }); t1.start(); t2.start(); t1.join(); System.out.println(main线程结束); } }代码说明当执行到wait,锁对象解锁wait进入休眠等待被同对象的notify唤醒wait被唤醒后继续执行后续代码。wait和notify的作用就是这么简单6.总结本章讲述了线程属性、线程的构造方法、创建线程的5种方式 和并发编程时多线程编程怎么解决或规避线程安全问题。如果只学了线程这些知识开发中还是不够的我们还得了解更多关于线程的知识下一章就是本章的延续会讲多线程的四大案例。