Java多线程基础——两万字详解

100 阅读22分钟

在这里插入图片描述

⭐️前言⭐️

🍉博客主页: 🍁【如风暖阳】🍁
🍉精品Java专栏【Javase】【Java数据结构】【备战蓝桥】【JavaEE初阶】
🍉欢迎点赞 👍 收藏留言评论 📝私信必回哟😁

🍉本文由 【如风暖阳】 原创,首发于 CSDN🙉

🍉博主将持续更新学习记录收获,友友们有任何问题可以在评论区留言

🍉博客中涉及源码及博主日常练习代码均已上传码云(gitee)


📍内容导读📍

🍅1.认识线程

🍉1.1进程与线程

1.1.1两者的概念认识

进程简单来说就是正在运行的程序,是可以通过双击执行的.exe文件,打开我们电脑的任务管理器,可以看到我们的电脑正在执行的进程,目前我们的电脑都是多进程模式。
在这里插入图片描述
但是因为进程是系统资源分配的基本单位,通过创建进程/销毁进程/调度进程都会产生较大的开销,于是程序猿就发明了“线程(Thread) ”的概念,线程在有些系统上也叫做“轻量级进程”,完成创建线程/销毁线程/调度线程要比创建进程/销毁进程/调度进程更高效。

而且进程与线程是包含关系,一个进程可以包含一个或多个线程,在这个进程里面创建线程,线程与进程共用资源,节省了资源的开销。

1.1.2两者的区别和联系【经典面试题】

  • 进程比线程更轻量,创建更快、销毁也更快。
  • 直观理解:进程是包含线程的,一个进程里可以有一个线程,也可以有多个线程。 类比:进程时工厂,线程是生产线
  • 系统角度:进程是操作系统资源分配的基本单位,线程是操作系统调度执行的基本单位。
  • 每个进程都有独立的内存空间(虚拟地址空间),同一个进程的多个线程之间,共用这个虚拟地址空间(例如共同使用同一个变量)。

🍉1.2第一个多线程程序

感受多线程程序和普通程序的区别:

  • 每个线程都是一个独立的执行流
  • 多个线程之间是 “并发” 执行的.
    (并发是指在CPU上完成多个线程的快速切换运行,在宏观上看起来好像多个线程在共同执行)
class MyThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("hello thread!");
            try {
                Thread.sleep(1000);//线程休眠
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class Demo1 {
    public static void main(String[] args) {
        Thread t=new MyThread();
        t.start();
        //t.run();
        while (true) {
            System.out.println("hello main!");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

//运行结果:
在这里插入图片描述
在这个程序中共有一个进程,里面有两个线程,一个是main方法对应的主线程,另一个线程是MyThread,run方法是新线程的入口方法。

1.2.1 start()和run()之间的区别【面试】

可以发现我在上述代码中,有一行注释掉的代码为//t.run();。

既然我们是想调用线程中的main方法,为什么不直接调用run方法,而要去调用start方法呢?
我们先来看运行结果:
在这里插入图片描述
我们可以发现,此时的两个线程不像上边的样例代码一样两个线程交替执行,而是执行MyThread线程。

当启动程序时,首先系统会创建一个进程,这个进程里已经包含了一个线程执行的代码,就是main方法,通过start方法,就会建立一个新的线程,从而两个线程可以并发执行,而如果调用run方法并没有创建新的线程,直接在main方法中形成阻塞,无法完成main方法中的线程。

总的来说,就是以下两点不同:
作用功能不同
1.run方法的作用时描述线程具体要执行的任务;
2.start方法的作用是真正的去申请系统线程

运行结果不同
1.run方法是一个类中的普通方法,主动调用和调用普通方法一样,会顺序执行一次;
2.start调用方法后,start方法内部会调用Java本地方法(封装了对系统底层的调用)真正的启动线程,并执行run方法中的代码,run方法执行完成后线程进入销毁阶段。

1.2.2使用jconsole命令观察线程

在这里插入图片描述
通过jconsole工具可以查看线程的信息

🍉1.3 创建线程

线程的创建方式有以下几种

1.3.1 继承Thread类,重写run方法

如1.2中的程序

1.3.2 实现Runnable接口,重写run方法

这种方法使得线程要完成的任务与线程本身分开了,让Runnable把任务提取出来,目的是“解耦合”

class MyRunnable implements Runnable {
    @Override
    public void run() {
        while (true) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class Demo2 {
    public static void main(String[] args) {
        //实现Runnable接口的类不能直接调用start方法,还需要实例化Thread来调用
        Thread t=new Thread(new MyRunnable());
        t.start();
    }
}

1.3.3 继承Thread类,重写run方法,使用匿名内部类的方式

匿名内部类就是脱离类来写方法,写起来更为简单。创建Thread的子类,同时实例化出一个对象。

public class Demo3 {
    public static void main(String[] args) {
        Thread t=new Thread() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("hello thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t.start();
    }
}

1.3.4 实现Runnable,重写run,使用匿名内部类

匿名内部类的实例作为构造方法的参数。

public class Demo4 {
    public static void main(String[] args) {
        Thread t=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("hello thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        t.start();
    }
}

1.3.5 使用lambda表达式来表示要执行的任务

lambda表达式,直接了当的指定任务,既不需要类也不需要方法

public class Demo5 {
    public static void main(String[] args) {
        Thread t=new Thread(()-> {
            while (true) {
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

1.3.6 总结

以上这些创建线程的方式,本质都相同,都是借助Thread类,在内核中创建新的PCB,加入到内核的双向链表中

🍉1.4多线程的优势-增加运行速度

多线程在一些场合下是可以提高程序的整体运行效率的。

  • 使用System.currentTimeMillis()可以记录当前系统的毫秒级时间戳.
  • serial 串行的完成一系列运算. concurrency 使用两个线程并行的完成同样的运算.

下边这段代码通过两种方式使得a,b各增加10亿次,比较两种方式的运行速度。

public class Demo6 {
    private static final long count=10_0000_0000L;
    //串行
    public static void serial() {
        long beg=System.currentTimeMillis();
        int a=0;
        for (int i = 0; i < count; i++) {
            a++;
        }
        int b=0;
        for (long i = 0; i < count; i++) {
            b++;
        }
        long end=System.currentTimeMillis();
        System.out.println("time: "+(end-beg));
    }
    //并发
    public static void concurrency() {
        long beg=System.currentTimeMillis();
        Thread t1=new Thread() {
            @Override
            public void run() {
                int a=0;
                for (long i = 0; i < count; i++) {
                    a++;
                }
            }
        };
        t1.start();
        Thread t2=new Thread() {
            @Override
            public void run() {
                int b=0;
                for (long i = 0; i < count; i++) {
                    b++;
                }
            }
        };
        t2.start();
        //需要保证t1,t2都执行完了之后再结束计时,所以需要让线程t1、t2插队
        try {
            //join就是等待对应的线程结束
            //当t1和t2没执行完之前,join方法就会使main线程阻塞等待
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long end=System.currentTimeMillis();
        System.out.println("time: "+(end-beg));
    }
    public static void main(String[] args) {
        serial();
        //concurrency();
    }
}

通过运行我们可以发现,通过利用线程并发的方式要比串行的运行速度快很多,但也不是正好提高一倍左右,因为在程序运行过程中会发生线程的调度,调度线程也会花一定的时间,这个调度时间完全不确定。

如果计算量大,计算的久,创建线程的开销就不是那么明显了。
如果计算量小,计算的快,这时线程的调度/创建/销毁都影响较大,此时多线程的提升就不那么明显了。

🍎2.Thread 类及常见方法

🍉2.1 Thread的常见构造方法

方法说明
Thread()创建线程对象
Thread(Runnable target)使用 Runnable 对象创建线程对象
Thread(String name)创建线程对象,并命名
Thread(Runnable target, String name)使用 Runnable 对象创建线程对象,并命名
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

Threadname存在的意义就是为了方便调试,如果线程多了就容易弄混,给线程起了一个名字之后,调试的时候就可以很清楚的看到当前的线程是谁。
如下代码:

public class Demo8 {
    public static void main(String[] args) {
        Thread t=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("hello thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        },"MyThread");
        t.start();
    }
}

在这里插入图片描述

🍉2.2 Thread 的几个常见属性

属性获取方法
IDgetId()
名称getName()
状态getState()
优先级getPriority()
是否后台线程isDaemon()
是否存活isAlive()
是否被中断isInterrupted
  • ID 是线程的唯一标识,不同线程不会重复
  • 名称在各种调试工具会用到
  • 状态表示线程当前所处的一个情况,下面我们会进一步说明
  • 优先级高的线程理论上来说更容易被调度到
  • 关于后台线程,需要记住一点:JVM会在一个进程的所有前台线程结束后结束运行(main线程也是前台线程)。

完成转账功能的服务器就类比为后台线程,每一笔转账都较为重要,必须全部完成才能结束进程。
微信运动步数的计算就类比为前台线程,步数并不需要那么精确,所以在每天00:00清零时并不需要完成所有计算。

  • 是否存活,即判定内核线程是否存在。

Thread对象虽然与内核中的线程一一对应,但其生命周期并不完全相同,在Thread对象实例化出来的时候,内核中并没有其对应的线程,只有在调用start方法时才会真正在内核中生成线程;而且当内核中的线程执行完了(run方法执行完了),内核的线程就销毁了,但是Thread对象还存在。

  • 线程的中断问题,下面我们进一步说明

代码示例:

public class Demo9 {
    public static void main(String[] args) {
        Thread t=new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    //打印当前线程的名字
                    //Thread.currentThread这个静态方法,获取到当前线程实例
                    //哪个线程调用这个方法,就能获取到对应的实例
                    System.out.println(Thread.currentThread().getName());
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        },"MyThread");
        t.start();
        //打印线程的属性
        System.out.println("id: "+t.getId());
        System.out.println("name: "+t.getName());
        System.out.println("state: "+t.getState());
        System.out.println("priority: "+t.getPriority());
        System.out.println("isDaemon: "+t.isDaemon());
        System.out.println("isInterrupted: "+t.isInterrupted());
        System.out.println("isAlive: "+t.isAlive());
    }
}

//
id: 12
MyThread
name: MyThread
state: TIMED_WAITING
priority: 5
isDaemon: false
isInterrupted: false
isAlive: true
MyThread
MyThread
MyThread
MyThread
MyThread
MyThread
......

🍉2.3 启动一个线程-start()

之前我们已经看到了如何通过覆写 run 方法创建一个线程对象,但线程对象被创建出来并不意味着线程就开始运行了,只有调用 start 方法, 才真的在操作系统的底层创建出一个线程。

🍉2.4 中断一个线程

让线程结束的关键,就是让线程对应的入口方法执行完毕
在这里插入图片描述
像这种情况,只要run执行完,线程就随之结束了

但是更多的情况下,线程不一定这么快能执行完run方法
在这里插入图片描述
如果run里面带的是一个死循环,此时这个线程就会一直持续运行,直到整个进程结束。

但是在实际开发中,我们并不希望线程的run就是一个死循环,更希望能够控制这个线程,按照咱们的需求随时结束~~

为了实现这个效果我们有下边两种方法:
1.使用boolean变量来作为循环结束标记

public class Demo10 {
    private static boolean flag=true;
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread() {
            @Override
            public void run() {
                while (flag) {
                    System.out.println("线程运行中...");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("线程结束!");
            }
        };
        t.start();
        //主循环等待3s,等待3s后就把falg改成false
        Thread.sleep(1000);
        flag=false;
    }
}
//
线程运行中...
线程运行中...
线程结束!

进程已结束,退出代码0

2.使用标准库里内置的标记

  • 获取线程内置的标记位:线程的isInterrupted()判定当前线程是不是应该要结束循环。
  • 修改线程内置的标记位:Thread.interrupt()来修改这个标记位。
public class Demo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread() {
            @Override
            public void run() {
                //获取线程标记位,默认情况下isInterrupted 值为 false
                while (!Thread.currentThread().isInterrupted()) {
                    System.out.println("线程运行中...");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t.start();
        //在主线程休眠3s后改变线程标记位,将Thread.currentThread().isInterrupted()给设成true
        Thread.sleep(3000);
        t.interrupt();
    }
}

此时我们发现运行结果会出问题
在这里插入图片描述
这是因为t.interrupt()方法有以下两种行为:
1.如果当前线程正在运行中,此时就会修改Thread.currentThread().isInterrupted()标记为true
2.如果当前线程正在sleep/wait/等待锁…此时会触发InterruptedException
为了解决上边代码出现的异常,就需要在catch语句中加入break,就会使程序正常运行了
在这里插入图片描述

🍉2.5 等待一个线程-join()

线程和线程之间,调度顺序是完全不确定的(取决于操作系统调度器自身的实现)但是有的时候,我们希望这里的顺序是可控的,此时线程等待就是一种办法。

这里的线程等待,主要就是控制线程结束的先后顺序。

一种常见的逻辑:
创建t1线程,再创建t2,t3,t4,让这三个新的线程来分别执行一些任务,然后t1线程最后在这里汇总结果。
这样的场景就需要t1的结束时机要比t2,t3,t4都迟,就像是领导给员工指派工作任务,需要等员工都完成各自的任务以后,老板还需要汇总。

代码示例:

public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread() {
            @Override
            public void run() {
                int count=0;
                while (count<5) {
                    count++;
                    System.out.println("线程运行中....");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("线程运行结束");
            }
        };
        t.start();
        //Thread.sleep(7000);
        System.out.println("join执行开始");
        t.join();
        System.out.println("join执行结束");
    }
}
//
join执行开始
线程运行中....
线程运行中....
线程运行中....
线程运行中....
线程运行中....
线程运行结束
join执行结束

进程已结束,退出代码0

当执行到t.join()代码时,调用这个代码的线程(main)就会阻塞等待(相当于让所调用的线程插队,本线程的代码不再继续往下走了)

假设调用join的时候,t线程已经结束了,会怎么样呢?
在这里插入图片描述
把上边代码示例中的注释行放开,让主线程先休眠7s,调用的线程其实已经完成了工作,结束了线程。
在这里插入图片描述
附录:
join无参数版本:相当于死等
join有参数版本,参数就是最大等待时间
在实际开发中,使用死等操作,往往是比较危险的…

典型的就是在网络编程中,发了一个请求,希望得到一个回应,由于种种原因回应没有到达,如果死等就会非常影响进行。

🍉2.6 获取当前线程引用

在这里插入图片描述
在这里插入图片描述
在这个代码中,看起来好像this和Thread.currentThread没啥区别,但是实际上,没区别的前提是使用继承Thread,重写run的方式创建的线程,但如果当前是通过Runnable或者lambda的方式,就不行了
在这里插入图片描述

🍉2.7 休眠当前线程

调用sleep方法,使得线程从就绪队列移到阻塞队列中。
在这里插入图片描述
仅有就绪队列中的线程才参与cpu的调度。

因为线程的调度是不可控的(即使线程在阻塞队列被唤醒到就绪队列,也并不是直接就能被调度到CPU上的),所以,这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的.

🍎3. 线程的状态

🍉3.1线程的所有状态

线程的状态用于辅助系统对于线程进行调度

public class Demo13 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()-> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        });
        System.out.println(t.getId()+": "+t.getState());
        t.start();
        Thread.sleep(1000);
        System.out.println(t.getId()+": "+t.getState());
        Thread.sleep(1000);
        t.interrupt();
        Thread.sleep(1000);
        System.out.println(t.getId()+": "+t.getState());
    }
}

在这里插入图片描述

  • NEW: Thread对象创建出来了,但是还未开始工作
  • RUNNABLE:可工作的。又可分为正在工作中和即将开始工作
  • TIMED_WAITING:按照一定的时间进行阻塞(调用sleep方法)
  • WAITING:死等(调用wait方法)
  • BLOCKED:等待锁的时候进入的阻塞状态
  • TERMINATED:工作完成了

🍉3.2 线程的状态和转移

在这里插入图片描述
我们可以通过下图的示例来理解上边的状态转移图
在这里插入图片描述
理解线程的状态,最大的意义在于未来调试一些多线程的程序

🍎4. 线程安全

🍉4.1线程不安全

我们通过下边这段代码来引出线程安全这个问题:

class Counter {
    public int count=0;
    public void increase() {
        count++;
    }
   
}
public class Demo2 {
    private static Counter counter=new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("counter: " + counter.count);
    }
}


//94182

以上代码的目的是想通过两个线程来完成count变量的10万次自增,预期应该打印出10万,但运行结果却不是这样的,这是由于多线程并发执行,导致代码中出现BUG,这样的情况就称为“线程不安全”。

下边我们来剖析这个程序执行的过程:
count++的详细过程分为三个步骤

  • 把内存中的值读取到CPU中 LOAD
  • 执行++的操作 ADD
  • 把CPU的值写回到内存中 SAVE

在这里插入图片描述
如果线程1的SAVE在线程2的LOAD之后,那么此时就会出现当前的线程不安全问题。

也就是说如果两个线程并发的执行count++,会抢占性的在CPU上执行,两个线程执行的具体顺序是完全不可预期的。

刚才的代码中,两个线程并发的自增了5w次,在这5w次里面,有多少次触发了类似于上边的“线程不安全问题”并不确定,最终结果是几也就不确定,但是可以知道的是,最终结果一定是5w——10w之间的数据。

🍉4.2线程不安全的原因

产生线程不安全的原因有以下五点:

1.线程之间是抢占式执行的【根本原因】

抢占式执行,导致两个线程里面操作的先后顺序无法确定,这样的随机性,就是导致线程安全问题的根本所在

2.多个线程修改同一个变量

一个线程修改同一个变量没有线程安全问题(不涉及并发,结果确定)
多个线程读取同一个变量也没有线程安全问题(读只是单纯把数据从内存放到CPU中,不管怎么读,内存的数据始终不变)
多个线程修改不同的变量,也没有线程安全问题,所以为了规避线程安全问题,就可以尝试变化代码的组织形式,达到一个线程只改变一个变量。

3.原子性

像++这样的操作,本质上是三个步骤,是一个“非原子”的操作,
像=操作,本质上就是一个步骤,认为是一个“原子"的操作

像当前咱们的++操作本身不是原子的,但是可以通过加锁的方式,把这个操作变成原子的

4.内存可见性(与编译器优化相关)

一个线程修改,一个线程读取,由于编译器的优化,可能把一些中间环节的SAVE和LOAD操作去掉了,此时读的线程可能读到的是未修改过的结果

5.指令重排序(也是和编译器优化相关)

编译器会自动调整执行指令的顺序,以达到提高执行效率的效果。
调整的前提是保证指令的最终效果是不变的。

🍎 5. synchronized 关键字

🍉5.1 synchronized 的特性

在产生线程不安全的五种原因下,我们如果想要解决线程安全问题,最普适的办法,就是通过“原子性”这样的切入点来解决问题。

我们在4.1中的代码示例Demo2中展示了线程不安全的问题,我们如何通过“原子性”的切入点来解决这个问题呢?
在这里插入图片描述

那就是在方法中加入synchronized关键字加锁,该关键字具有以下特性:

1) 互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
    在这里插入图片描述

synchronized用的锁是存在Java对象头里的
![在这里插入图片描述](https://img-blog.csdnimg.cn/32515079db234fa2a400a658421736b7.png)

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).

如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
在这里插入图片描述

理解 “阻塞等待” .
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
注意:

  • 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
  • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.

2) 刷新内存(保证内存可见性)
4.2中介绍了线程不安全的原因,其中的第四条内存可见性就是由于编译器的优化而导致的,如果加上synchronized 关键字,就会禁止这样的优化,保证每次进行操作的时候都会把数据真的从内存中读,也真的写回内存中。

3) 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

理解 “把自己锁死” 一个线程没有释放锁, 然后又尝试再次加锁
在这里插入图片描述
第一次加锁,加锁成功

第二次再尝试对这个线程加锁的时候,此时对象头的锁标记已经是true,线程就要阻塞等待,等待这个锁标记被改成false然后才重新竞争这个锁,但是第一个锁并不能释放,此时就出现死锁了

Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.(如果因为上面的情况而出现死锁,说明是不可重入锁

🍉5.2 synchronized 使用示例

synchronized的使用方法有这几种,一是修饰方法,锁对象相当于this(如果修饰的是静态方法,锁对象相当于类对象);二是修饰代码块,锁对象在()指定;。我们下边来看看这些方法:

class Counter {
    public int count=0;
    public synchronized void increase() {
        count++;
    }
    public void increase2() {
        synchronized (this) {
            count++;
        }
    }
}

increase()是修饰方法,如果要加锁的代码不一定是一整个方法,而是方法中的部分代码,就需要对代码块进行加锁,比如increse2()。

如果要对代码块进行加锁,需要指明锁对象。
在这里插入图片描述
如上图所示的常见的几种锁对象,1是this,就是针对当前对象进行加锁,谁调用increase2()方法,谁就是this;2是针对类对象进行加锁,类对象是单例的;3是针对普通成员加锁。

注意:
注意synchronized修饰的是this对象还是类对象

如果是this对象,这个时候只有在多个线程并发的调用该对象的方法时,才会触发锁竞争,但如果是类对象(.class文件),由于类对象是单例的,多个线程并发的调用该对象的方法,一定会触发锁竞争。

我们实际在写多线程的代码时,并不关心这个锁对象究竟是谁,是哪种形态,只是关心两个线程是否是锁同一个对象是锁同一个对象就有竞争,锁不同对象就无竞争

🍉5.3 Java 标准库中的线程安全类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施。

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的. 使用了一些锁机制来控制.

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的

  • String

🍎 6. volatile 关键字

🍉6.1 volatile 能保证内存可见性

volatile 修饰的变量, 能够保证 “内存可见性”.

代码示例

import java.util.Scanner;
public class Demo16 {
    static class Counter {
        public int flag=0;
    }
    public static void main(String[]args) {
        Counter counter=new Counter();
        Thread t1=new Thread(()-> {
            while (counter.flag==0) {
            }
            System.out.println("循环结束");
        });
        Thread t2=new Thread(()-> {
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数:");
            counter.flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}
//

以上这段代码的两个线程,都是操作同一个静态内部类,第二个线程完成静态内部类里数据的更改,第一个线程根据该静态内部类里的数据判断循环是否结束。
在这里插入图片描述
但是程序运行结果并不像我们预期的逻辑(线程1没有正常结束循环),通过第二个线程完成数据更改后,第一个线程的循环也应该终止。

这是因为在线程1中会进行两步操作,一是从内存中读取flag的值(LOAD),二是比较(COMPARE),因为LOAD的开销比CMP大,且编译器发现没人改count这个变量,所以编译器就进行了优化,并不会每次都从内存里读取flag的值,而是读了一次之后,后续都直接从CPU中来读flag的值了,此时线程2的修改并不能让线程1感知,所以并不会按照预想逻辑是程序终止。

那我们该如何解决这个问题呢,就是使用volatile来禁止编译器进行刚才的优化
在这里插入图片描述

一旦给这个flag加上volatile之后,此时后续的针对flag的读写操作,就能保证一定是操作内存了
这是更改后程序的运行结果:
在这里插入图片描述

🍉6.2 volatile 不保证原子性

volatile 保证的是内存可见性,但不能保证原子性
代码示例
这个是最初的演示线程安全的代码.

  • 给 increase 方法去掉 synchronized
  • 给 count 加上 volatile 关键字.
static class Counter {
    volatile public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(counter.count);
}

此时可以看到, 最终 count 的值仍然无法保证是 100000.

🍉6.3 volatile与编译器优化密切相关

编译器优化,是一个相当复杂的事情,啥时候优化,啥时候不优化,优化优化到什么程度,我们都不好把握。
代码示例:

	static class Counter {
         public int flag=0;
    }
    public static void main(String[]args) {
        Counter counter=new Counter();
        Thread t1=new Thread(()-> {
            while (counter.flag==0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("循环结束");
        });
        Thread t2=new Thread(()-> {
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数:");
            counter.flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }

如果flag属性并没有被volatile修饰,而是在线程1的循环中加入了休眠时间,线程1也能正常结束循环,这就是改变了代码的优化方式,通过volatile关键字,可以强制编译器进行内存读取禁止优化

一般来说,如果某个变量,在一个线程中读,一个线程中写,这个时候大概率需要使用volatile。

🍉6.4 volatile作用总结

1.保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另一个线程能够读到这个修改的值。
2.保证有序性:禁止指令重排序。编译时JVM编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。

注意:volatile不能保证原子性

🍎 7.wait 和 notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

完成这个协调工作, 主要涉及到以下方法:

  • wait() / wait(long timeout): 让当前线程进入等待状态.
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程.

注意: wait, notify, notifyAll 都是 Object 类的方法.

🍉7.1 wait()方法

wait 做的事情:

1.释放当前的锁(要想使用wait/notify,必须搭配synchronized(否则会直接抛出异常),需要先获取锁,才有资格谈wait)
2.进行等待通知
3.满足一定条件时被唤醒, 重新尝试获取这个锁.

注意:1和2是要原子完成的

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出InterruptedException 异常

代码示例:观察wait()方法使用

public static void main(String[] args) throws InterruptedException {
    Object object = new Object();
    synchronized (object) {
        System.out.println("等待中");
        object.wait();
        System.out.println("等待结束");
   }
}

这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。

🍉7.2 notify()方法

notify 方法是唤醒等待的线程.

  • 方法notify()也要在同步方法或同步块中(synchronized)调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程,其他线程保持原状。
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

在这里插入图片描述

代码示例:

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        //专门准备一个对象,保证等待和通知是一个对象
        Object locker = new Object();

        //第一个线程,进行wait操作
        Thread t1 = new Thread(() -> {
            while (true) {
                synchronized (locker) {
                    System.out.println("wait之前");
                    try {
                        locker.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //此处写的代码,一定是在notify之后执行的
                    System.out.println("wait之后");
                }
            }
        });
        t1.start();

        Thread.sleep(500);
        //第二个线程,进行notify
        Thread t2 = new Thread(() -> {
            while (true) {
                synchronized (locker) {
                    System.out.println("notify之前");
                    //此处写的代码,一定是在wait唤醒之前执行的

                    locker.notify();
                    System.out.println("notify之后");
                }
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t2.start();
    }
}

在这里插入图片描述
注意:不仅要保证加锁的对象和调用wait的对象是同一个对象,还要保证调用wait的对象和调用notify的对象也是同一个对象,才能顺利完成wait、notify的过程。

🍉7.3 notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.(即使唤醒了所有的wait,这些wait又需要重新竞争锁,重新竞争锁的过程仍然是串行的)

🍉7.4 wait 和 sleep 的对比(面试题)

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间。

当然为了面试的目的,我们还是总结下:

  1. sleep操作是指定一个固定时间来阻塞等待,wait既可以指定时间,也可以无限等待
  2. wait唤醒可以通过notify或者interrupt或者时间到来唤醒,sleep唤醒通过时间到或者interrupt唤醒
  3. wait主要的用途就是为了协调线程之间的先后顺序,这样的场景并不适合使用sleep。sleep单纯让该线程休眠,并不涉及到多个线程的配合。

⚡️最后的话⚡️

总结不易,希望uu们不要吝啬你们的👍哟(^U^)ノ~YO!!如有问题,欢迎评论区批评指正😁

在这里插入图片描述