JavaEE:多线程初阶

目录

一.线程

1.线程是什么

2.多线程编程

3.认识线程类Thread

3.1.Thread的常用构造方法

3.2.Thread常见的属性

4.实战线程

4.1.创建线程的五种方式

4.2.线程常见属性的实例

4.2.1获取线程ID

4.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.synchronized

5.2.1互斥

5.2.2可重入

5.3.wait和notify

5.3.1wait和notify的介绍

5.3.2方法的使用

6.总结


一.线程

"线程"的概念在上一篇文章"进程与线程的区别和联系"中有详细的讲解,下面也会重新讲解重要概念。

1.线程是什么

线程,可以想象成,一个"盒子"里面实现了一堆要运行的代码,这个盒子就是线程。操作系统通过cpu调用线程,并执行其中的代码,完成线程的使命!

2.多线程编程

多线程编程就是"并发编程"。我们在多个线程中实现代码,为什么就叫"并发编程"?其实是因为有操作系统的随机调度,这层背景。并发的详细定义在"进程与线程的区别和联系".

注意,多线程之间可能"并行执行"也可能"并发执行",程序员不用关注是哪种执行方式,我们既知道不了,也干预不了。

3.认识线程类Thread

3.1.Thread的常用构造方法

|-------------------------|-------------------------------------------|
| 构造方法 | 解释 |
| Thread() | 创建一个线程 |
| Thread(Runnable) | 创建一个参数只允许传入Runnable接口,或实现了Runnable接口的类的线程 |
| Thread(String) | 创建线程,并给线程起个名字 |
| Thread(Runnable,String) | 上面两个解释的和 |

3.2.Thread常见的属性

|----------|-----------------|
| 线程属性 | 获取方法 |
| ID | getId() |
| 名称 | getName() |
| 状态 | getState() |
| 优先级 | getPriority() |
| 是否存活 | isAlive() |
| 是否后台线程 | isDaemon() |
| 是否终止了 | isInterrupted() |

  • 每个线程独有一个ID,ID是线程的唯一标识。
  • 名称是各种调试工具用到。
  • 状态表示线程所处的情况。
  • 优先级高的,理论上来说更容易被调用。
  • 判断线程是否正在运行(run方法是否运行结束)。
  • 是否是后台线程。就算后台线程还在运行,非后台线程(前台线程)运行结束,进程就结束(关闭)了。
  • 线程是否终止,是则返回true,否返回false。

后面会实例线程,使用线程方法,并作出细节说明!!

4.实战线程

4.1.创建线程的五种方式

设计好run()后,使用"引用.start()"启动线程,线程启动后会自动调用run方法,这个run方法就是线程要执行的任务,所以run必须设计好.

上述,采用创建一个类继承Thread,并重写run方法的方式创建线程,除这种方式能创建线程外,还有四种创建的方式。

java 复制代码
//方式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获取线程ID
4.2.2获取线程名称
4.2.3获取线程状态

Java给线程设定了这么几种状态:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED

|---------------|----------------------------------------------------------|
| 状态 | 说明 |
| NEW | 创建了线程,但还未启动线程 |
| RUNNABLE | 正在工作或即将工作(随叫随到) |
| BLOCKED | 由于锁导致的阻塞状态 |
| WAITING | 死等,没有超时时间的等待 |
| TIMED_WAITING | 1.由设定了超时时间的阻塞;2.线程阻塞(由于线程没吃到cpu资源,就不继续执行线程内容)引起的有时间限制的阻塞 |
| TERMINATED | 工作完成 |

NEW、RUNNABLE

BLOCKED

(不了解"锁"的,可以先看下面"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.线程安全问题

"线程安全问题"又叫"线程不安全",通过下面一个示例,了解保障线程安全的重要性。

java 复制代码
public class Test6 {
    public static int count =0;

    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();
        t2.start();
        //main线程等待t1、t2线程结束
        t1.join();
        t2.join();
        System.out.println("count= "+count);
    }
}

按上述逻辑count最后应该等于100000才符合o预期。

原因解释:图下附带说明

图片说明:count++语句在操作系统中是三步操作,线程一在执行完第一步操作后,cpu就被调走了;线程二被调用,执行了三步操作,count值加1,等于1;然后线程一被调用,由于"线程上下文"功能,寄存器就从第二步开始执行,因为寄存器中的值还是0,所以0+1=1,执行第三步,将1读回内存,赋给count的值为1,所以进行两次count++语句,count还等于1。

除了上述这种情况,还有但不限于:

5.1线程不安全的原因

1)(根本)操作系统对线程的调度是随机的。抢占式执行。

2)多线程同时修改同一个变量。

3)修改操作,不是原子的(原子性)。(如果一条语句只对应一条CPU指令,就认为是原子的,那么就不会出现"一条语句执行一半"的情况)

4)内存可见性问题。

5)指令重排序问题。

这些都是引起线程安全问题的原因,根据原因我们可以自己解决开发中的安全问题。

5.1.1多线程同时修改同一个变量

首先不能从原因1下手解决问题,因为程序员修改不了操作系统;

可以从原因2入手,把"并发编程"风格代码,改成"串行"风格。修改如下:

java 复制代码
public class Test7 {
    public static int count=0;
    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("count="+count);
    }
}

这样等待一个线程结束再执行另一个线程的方式就是"串行执行",避免多个线程同时修改同一个变量,出现线程安全问题。但上面的方案还不够通用,第二种方案是加"锁"。方案二解决了原因3。

5.1.2修改操作,不是原子的

方案二:

java 复制代码
public class Test8 {
    public static int count=0;
    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("count="+count);
    }
}

synchronized说明:

synchronized是一个锁;synchronized()的括号中写入一个对象(这个对象又叫"锁对象",锁对象可以是任何类型),进入synchronized代码块就对"锁对象"上锁,出代码块就解锁;在多线程编程中,锁对象还未解锁,t2线程就被调度,执行synchronized对同一个锁对象加锁,这样会造成t2线程阻塞等待,只有等待前面的synchronized执行完,将锁对象解锁了其他线程才有机会上锁。只有锁对象相同才会产生阻塞效果。(synchronized在5.2详细介绍
上述代码解释:

t1、t2线程要执行的run()中,synchronized的锁对象是同一个,所以在先执行的synchronized结束前,t2线程中的synchronized是不能被执行的。因此count=100000;

5.1.3内存可见性问题

解决原因4。

产生内存可见性问题的背景:JVM会主动优化我们写的代码,但程序逻辑不会改变,被优化后的代码就可能出现"内存可见性问题",例如下面代码

java 复制代码
import java.util.Scanner;

public class Test9 {
    public static int num=0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(num==0){
                //......
            }
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(()->{
            Scanner sc = new Scanner(System.in);
            System.out.println("输入num值");
            num=sc.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就把判断条件num==0的认为恒成立,所以就算后续num的值不等于0,while循环也不受影响。

这样变量 被JVM优化后导致的线程安全问题,就叫"内存可见性问题",使用volatile修饰变量,可以解决内存可见性问题;(用volatile修饰变量,就是在告诉JVM不能优化这个变量;volatile只能修饰变量)

修改如下:

java 复制代码
public volatile static int num=0;
5.1.4指令重排序
java 复制代码
public class Test10 {
    //代码背景:多线程环境下
    private static Test10 instance  = null;
    public static Object object = new Object();
    public static Test10 getInstance(){
        //外层if是判断instance是否需要加锁,如果没有外层if的话每次都要竞争锁,会阻塞等待,减低效率
        if(instance==null){
            synchronized(object){
                //里面这个if是为了,判断是否给instance new对象,在并发编程下,可能在外层if判
                // 断为空,进入外层if,但由于随机调度的原因,可能instance又有对象了,如果进入
                // synchronized不再次判断就会再次new对象
                if(instance==null){
                    instance=new Test10();
                }
            }
        }
        return instance;
    }
}

上述代码看似没问题,但其实暗藏玄机!

instance=new Test10;语句涉及三步操作,1)申请内存;2)在空间上构造对象;3)内存空间的首地址,赋值给引用变量。

一般是1、2、3这样的执行顺序,但也有可能1、3、2,当执行完3后,CPU去调度其他线程,结果其他线程判断出instance不为空(虽然该引用变量指向的空间没有任何东西,但是它依然不等于空),就直接return instance。这样因为执行指令的顺序改变,而引起线程安全问题的原因就叫"指令重排序"。

解决"指令重排序"的方法也很简单,就是使用volatile修饰变量。

volatile有两个作用:1.确保每次读取操作,都是都内存;2.volatile修饰的变量在读取或修改,不会触发重排序。

5.2.synchronized

synchronized语法:

synchronized(锁对象){

//加锁内容

}

说明:synchronized是一个锁,锁对象可以是任意类型的对象,Object类型、Student类型都行,例如5.1.1.2的修改例子,synchronized的锁对象就是Object类型。

5.2.1互斥

synchronized有互斥效果;

  • 进入synchronized修饰的代码块,对锁对象自动加锁;
  • 退出synchronized代码块,锁对象自动解锁;

某个线程正在执行synchronized代码块,另一个(或另一些)线程如果也执行到同一个对象的synchronized,那后面这个synchronized就会阻塞等待,等待对象解锁后,才有机会竞争到锁(简单来说就是,我和你有同一个对象,我先对这个对象上锁了,我的事还没做完,你就算也有事,你也得等我结束了才行)。互斥效果在count==10万前后对比明显。

5.2.2可重入

讲到可重入就离不开"死锁"的情况。

死锁的第一种典型情况如下:

java 复制代码
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--;当count==0时,这个可重入锁就结束了。(Java中这样嵌套可以运行,当C++则会真正死锁)

下面就不涉及可重入知识点,但也是典型的死锁方式。

死锁的第二种典型情况:

java 复制代码
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嵌套B,B嵌套A产生的死锁。

5.3.wait和notify

由于线程之间是抢占式执行的,因此线程的先后执行顺序是难以预知的。

但在开发中,程序员更希望线程的执行顺序是可预知。这时使用wait和notify就能控制线程的执行顺序。

5.3.1wait和notify的介绍

wait和notify是Object类的两个方法,都无返回值。

wait()和notify()必须 在synchronized内使用(否则报错),而且调用这两个方法的对象必须和synchronized的锁对象相同,否则报错。看下面的伪代码,就能明白。

java 复制代码
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方法的使用
java 复制代码
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种方式 和 并发编程时(多线程编程)怎么解决或规避线程安全问题。

如果只学了线程这些知识,开发中还是不够的,我们还得了解更多关于线程的知识,下一章就是本章的延续,会讲多线程的四大案例。

相关推荐
敲代码的小王!2 小时前
prompt开发游戏-哄哄模拟器
java·游戏·ai·prompt
筱顾大牛2 小时前
缓存更新策略
java·redis·缓存
Lyyaoo.2 小时前
Lombok工具库
开发语言·python
sheji34162 小时前
【开题答辩全过程】以 慧医疗网上医院管理系统为例,包含答辩的问题和答案
java
子一!!2 小时前
JavaEE初阶第一课时==计算机与系统讨论==
java·java-ee
小二·2 小时前
Go 语言系统编程与云原生开发实战(第37篇)
java·云原生·golang
yxc_inspire2 小时前
大二 Java 后端学习记录:集合框架(List/Queue/Map/Set)+ 泛型 + 迭代器
java·开发语言
xuansec2 小时前
【JavaEE安全】Java反射机制:核心原理、实战应用与安全风险管控
java·安全·java-ee
co_wait2 小时前
【C++ STL】map容器的基本使用
java·c++·rpc