多线程基础入门

文章目录


前言

并发是计算机发展到今天的必然要求,如今随便一台计算机都是四核,八核CPU,该如何把这些CPU的资源充分利用,是我们需要思考的问题


一、认识线程

(一)概念

1.线程是什么

  一个线程就是一个“执行流”,如果将进程比作工厂,那么线程就相当于流水线

2.为啥要有线程(轻量级进程)

前提:“并发编程”成为了“刚需”

1.单核CPU的硬件发展遇到瓶颈,为了提高算力,一个计算机就搭载了多核CPU,并发编程可以更充分的利用多核CPU资源
2.通过多进程也是可以实现并发编程的,但是无论是创建进程,频繁的调度进程,还是销毁进程,成本都比较高。一种思路是进程池,但是不使用的时候需要浪费大量资源。因此就引出了线程,线程的优势也很明显

  • 创建线程比创建进程更快
  • 销毁线程比销毁进程更快
  • 调度线程也比调度进程更快
为什么线程比进程更轻量

  因为创建进程的时候必须要分配资源(主要是内存和文件),而线程是包括在进程中的,即一个进程可以包括多个线程,多个线程可以共用这一份内存和文件,因此只有在创建第一个线程时需要同时创建一个进程,此时成本较高,之后再在这个进程进程中创建线程时,成本就低得多了

  但是!!!,线程并不是越多越好,当线程太多时,就需要抢占资源(整体硬件资源是有限的),整体的速度就会收到影响

经典面试题:谈谈进程和线程的区别和联系

回答:
  1.首先进程包含线程,一个进程中可以有一个线程,也可以有多个线程,每个进程中至少有一个线程存在,称为主线程
  2.进程和线程都是为了处理并发编程的问题。但是进程有问题,频繁创建和释放时效率太低,相比之下线程创建和释放时的效率就要高很多了。(为什么呢?原因就是每个进程都有自己专属的内存空间和文件等等一些资源,因此每次创建或者释放进程都是需要对这些资源进行分配和释放的,而对于线程来说,可以多个线程共用一个进程的资源,只有创建第一个线程时操作系统才需要顺带创建进程来给分配内存和文件之类的资源,之后再在这个进程中创建线程时,成本就低得多了)
  3.操作系统创建进程,要给进程分配资源,进程是操作系统分配资源的基本单位;操作系统创建的线程,是要在CPU上调度执行,线程是操作系统调度执行的基本单位
  4.进程具有独立性,每个进程都有自己的虚拟地址空间,一个进程挂了,不会影响其他的进程;而同一个进程中的多个线程,共用一个内存资源,如果一个线程挂了,可能就会影响其他线程,甚至导致整个进程崩溃

3.线程的结构

  和之前进程类似,之前进程介绍的PCB结构其实是一个进程中只有一个线程时的结构,而引入多线程之后,PCB就表示线程结构,一个线程对应一个PCB,即一个进程中可能有多个PCB,PCB中有一个tgroupId,这个tgroupId就是线程的id,同一个线程中的进程tgroupId都是一样的,不同的线程pid不同
  Linux内核不区分线程和进程,线程进程是程序猿为了写应用程序代码而搞出来的词。实际上Linux只认PCB,内核中PCB被称为轻量级进程

(二)第一个多线程程序

运行代码:

class MyThread extends Thread {
    @Override
    public void run() {
        while(true) {
            System.out.println("Thread!");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

public class ThreadTest {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new MyThread();
        thread.start();
        while(true) {
            System.out.println("world");
            Thread.sleep(1000);
        }
    }
}

运行结果:
多线程执行结果

(三)创建线程

1.继承Thread类

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread!");
    }
}

2.实现 Runnable 接口

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("hello");
    }
}

public class ThreadTest {
    public static void main(String[] args) {
        Runnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();
    }
}

3.匿名内部类创建Thread的子类

public static void main(String[] args) {
    Thread thread = new Thread() {
        @Override
        public void run() {
            System.out.println("hello");
        }
    };
    thread.start();
}

4.匿名内部类实现Runnable接口

public static void main(String[] args) {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("hello");
        }
    });
    thread.start();
}

5.lambda表达式实现Runnable接口

public static void main(String[] args) {
    Thread thread = new Thread(() -> System.out.println("hello"));
    thread.start();
}

二、Thread类和常见方法

Thread类是 JVM 用来管理线程的一个类,因此每一个线程都有唯一的Thread对象与之关联

(一)Thread常见构造方法

方法说明
Thread()创建线程对象
Thread(Runnable target)使用Runnable对象创建线程对象
Thread(String name)创建线程对象并命名
Thread(Runnable target, String name)使用Runnable对象创建线程对象并命名
【了解】Thread(ThreadGroup group, Runnable target)线程可以被用来分组管理,分好的组即为线程组,目前了解即可

(二)Thread的几个常见属性

属性获取方法
IDgetId()
名称getName()
状态getState()
优先级getPriority()
是否后台线程isDaemon()
是否存活isAlive()
是否被中断isInterrupted()
  • ID是线程的唯一标识,不同线程不会重复
  • 名称是各种调试工具用到
  • 状态表示线程当前所处的一个情况
  • 优先级高的线程理论上更容易被调度到
  • 对于后台线程来说,JVM会在一个进程的所有非后台线程结束后,才会结束运行
  • 关于存活,判断run方法是否运行结束
  • 线程中断的情况复杂,下面具体介绍

(三)启动一个线程——start()方法

明确start方法与run()方法的区别

  • 覆写run方法是明确线程将要执行的任务内容,run方法只是一个普通的方法
  • 创建Thread对象可以理解为把一切工具准备就绪
  • 调用start()方法,就是开工!此时,线程才真正被执行起来

注意,调用start()方法,才真的在操作系统底层创建出一个线程

(四)中断一个线程

让一个线程停下来

1.手动设置一个标志位

该方法是在一个线程中令其他其他线程终止,这种方法只适用于同一个进程中的线程,因为它们共用一个虚拟地址空间

public class ThreadTest {
    private static boolean isQuit = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while(!isQuit) {
                System.out.println("hello");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        Thread.sleep(5000);
        isQuit = true;
        System.out.println("终止 t 线程");
    }
}

2.使用Thread中内置的一个标志位来进行判定

使用 Thread.interrupted()(静态方法)或者Thread.currentThread().isInterrupted()(实例方法) 代替自定义标志位。Thread内部包含了一个boolean类型的变量作为线程是否被中断的标记

方法说明
public void interrupt()中断对象关联的线程,如果线程正在阻塞,则以异常方式通知,否则设置标记位
public static boolean interrupted()判断当前线程的中断标志位是否设置,调用后清除标志位
public boolean isInterrupted()判断对象关联的线程的标志位是否设置,调用后不清除标志位

推荐使用第三种,第二种判定的是Thread的静态成员变量,但是一个进程中不一定只有一个线程,因此只有一个线程一个标志位才不至于影响其他线程或被其他线程影响

使用 Thread 对象的 currentThread().isInterrupted() 方法通知线程结束

代码示例:

public class ThreadTest {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
           while(!Thread.currentThread().isInterrupted()) {
               System.out.println("hello");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

运行结果:
使用Thread类内部自带的标志位中断

  调用t.interrupt()方法可能产生两种情况:

1.如果 t 线程是处在就绪状态,那么直接设置线程的标志位为 true
2.如果 t 线程处在阻塞状态(sleep 休眠了),就会触发InterruptException~。这种情况下,printStackTrace 只是打印当前出现异常位置的代码调用栈,打完日志之后,就继续执行了

情况二解决办法:在打印日志时后面加上break;即可,如果需要收尾,可以在打印日志和break之间进行

(五)线程等待-join()

  由于系统调度线程的顺序是随机的,但有时会存在一个线程需要等待另一个线程执行完某个操作之后再被调度执行,这种情况下就要用到线程等待操作,人为的控制一些线程的调度顺序。线程等待,就是其中一种控制线程执行顺序的手段。一个线程

方法说明
public void join()等待线程结束
public void join(long millis)等待线程结束,最多等millis毫秒
public void join(long millis, int nanos)同理,可以设置更高精度

方法说明

1.调用join()时,哪个线程调用的join,哪个线程就会阻塞等待,直到对应的线程执行完毕为止(对应线程的run()方法执行完毕),这个方法相当于死等,只有当run()方法执行完才结束
2.join(long millis)方法就可以设置一个最多等待的毫秒,给等待设置一个上限,如果没到上限时间run就执行完,那么退出,如果到了上限时间run方法还没执行完,那么也直接退出

代码示例:

public class ThreadTest {
    public static void main(String[] args) {
        test2();
    }
    private static void test2() {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("代码执行结束");
    }
}

运行截图:
线程等待代码示例

(六)获取当前线程引用

方法说明
public static Thread currentThread();返回当前线程对象的引用

方法说明
  该方法是一个静态方法,因此在谁里面调用该方法,那么返回的就是谁的引用,这个操作给实现Runnable接口和lambda表达式这种创建线程的方法创造了更多的发挥空间

代码示例:

public class ThreadTest{
    public static void main(String[] args) {
        Thread t = new Thread(){
            @Override
            public void run() {
                //this只能是在继承Thread类这种创建进程的方法中才能使用
                //System.out.println(this.getName());
                System.out.println(Thread.currentThread());
                System.out.println(Thread.currentThread().getName());
            }
        };
        t.start();
    }
}

运行结果:
获取当前线程引用

(七)线程休眠

  我们说操作系统(以Linux为例)中,是使用双向链表进行存储PCB,实际上,双向链表不止一个,包括了就绪队列,阻塞队列等
  操作系统进行调度时,是在就绪队列中选择合适的PCB进行调度,阻塞队列的PCB是没有机会被调度的,而我们将进程休眠,实际上就是把一个进程从就绪队列转到阻塞队列,休眠时间到了之后,系统再重新把PCB转回就绪队列等待系统调度

  明确一点,线程调度是不可控的,因此线程休眠不是保证线程在设置的时间到达之时被调度,而是保证在设置的时间到达之前线程不被调度,实际调度的时机一般比设置时间更长

方法说明
public static void sleep(long millis) throws InterruptedException休眠当前线程 millis 毫秒
public static void sleep(long millis, int nanos) throws InterruptedException相比较第一种,更高精度的休眠

代码示例:

public class ThreadDemo13 {
    public static void main(String[] args) throws InterruptedException {
        long beg = System.currentTimeMillis();
        Thread.sleep(3000);
        long end = System.currentTimeMillis();
        System.out.println(end - beg);
    }
}

运行结果:
线程休眠

三、线程的状态

(一)线程的所有状态

线程的状态是一个枚举类型 Thread.State

  1. NEW: 安排了工作, 还未开始行动

线程对象创建好了,但是还没调用start的时候

  1. RUNNABLE:可工作的,又可以分成正在工作中和即将开始工作.(对应操作系统的就绪状态)

调用start之后大概率处在这个就绪状态,随时可以被系统调用或者已经被系统调度执行

  1. BLOCKED:表示排队等着其他事情

线程在等待锁,处于阻塞状态

  1. WAITING:表示排队等着其他事情

线程在等待唤醒,处于阻塞状态(阻塞状态之一)

  1. TIMED_WAITING:都表示排队等着其他事情

线程处在sleep,或者join(超时时间),就会进入到TIMED_WAITING状态,表示当前线程在一定时间内,是阻塞状态(阻塞状态之一)

  1. TERMINATED: 工作完成了.

线程的run方法执行完了但是线程对象还没释放的时候

之所以分这么多状态,就是因为编程过程中程序会经常“卡死”,在分析卡死的原因的时候就要先看看当前程序中的各种关键线程所处状态,从状态中我们就大概直到问题是什么
线程状态转换图

四、线程安全问题(重点

(一)概念

如果多线程环境下代码运行的结果是符合我们预期的,即无论如何运行都与对应的单线程环境下运行结果相同,则说明这个程序是线程安全的
线程不安全代码示例:

public class ThreadDemo15 {
    public static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for(int i = 0; i < 50000; i++) {
                    a++;
                }
            }
        };
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for(int i = 0; i < 50000; i++) {
                    a++;
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(a);
    }
}

运行结果:
线程安全问题运行结果1
线程安全问题运行结果2
线程安全问题运行结果3

如上图所示,每次运行结果都不一样,且我们的目的是让a自增到100000,不然结果一直在50000~100000之间,不能保证a一定能达到100000

(二)线程不安全原因

1. 万恶之源:线程的抢占式调度执行

2.修改共享数据

  上面的线程不安全的代码中,涉及到多个线程对同一个 a 变量进行修改,此时的 a 是一个多个线程都能访问到的“共享数据”
可以的话调整代码结构

3.原子性

  原子性的概念就是说一个操作已经无法再分成更小的操作了,在执行的时候不会被打断,也不会有操作来掺一脚

一条Java语句不一定是原子的,也不一定是一条指令
如上述代码中,a++一条语句其实包括了三条指令

  1. 把数据从内存中读到CPU
  2. CPU中对数据+1
  3. 把CPU中的数据写回到内存中

上述a++操作无法保证原子性
不能保证原子性会给多线程带来什么问题
  如果一个线程在对一个变量进行操作时,中途其他线程插入进来了,如果影响到操作,结果可能就是错误的
最关键的问题还是线程是抢占式调度执行的

如图所示,下面是正确结果应该的执行顺序(t1和t2三条指令作为一个整体),但不是系统执行指令的唯一顺序:
数据自增过程指令执行顺序1
其他执行顺序都会导致数据的脏读问题
例如:
数据自增过程指令执行顺序2
这种情况导致的结果就是数据只自增了一次

解决方案:synchronized

4.内存可见性

可见性指,一个线程对共享变量值的修改,能够及时的被其他线程看到

Java内存模型(JVM):Java虚拟机规范中定义了Java内存模型
  目的是屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果

  • 线程之间的共享变量存在主内存(系统内存)
  • 每个线程都有自己的工作内存(CPU的各种寄存器)
  • 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据
  • 当线程要修改一个共享变量时,也会先把修改工作内存中的副本,再同步回主内存

由于每个线程都有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的“副本”。此时修改线程1的工作内存中的值,线程2的工作内存不一定会及时变化

代码示例:

public class ThreadTest{
    static int isQuit = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                //Java编译器进行代码优化(在保证逻辑不变的情况)
                //可能会不再从主内存中读取数据
                //而是直接从工作内存中读取数据,导致t1死循环
                while(isQuit == 0) {

                }
                System.out.println("循环结束,t1线程执行完毕");
            }
        };
        t1.start();
        Thread.sleep(2000);
        isQuit = 1;
        System.out.println("main线程执行完毕");
    }
}

运行结果:
内存可见性问题

  如图所示,按道理说,当main线程执行完毕之后,isQuit = 1,t1线程也该执行完毕,但是t1线程并没有。原因就是Java编译器在保证逻辑不变的情况下会进行代码优化,而由于多线程问题比单线程复杂的多,编译器优化时认为isQuit是不会改变的,所以从主内存中读取一次数据之后,以后每次读取数据都是直接从工作内存中读取,导致循环停不下来,此时就会出现内存可见性问题 而编译器这么做的原因就是为了提高工作效率,众所周知,寄存器的读取速度比从内存中读取数据的速度要快上3 ~ 4个数量级

解决方案:synchronized或者volatile,其他方法比如sleep其实也可以,但是编译器如何优化我们不得而知,因此采取明确的方案最合适

5.指令重排序

代码重排序具体样例:例如,程序需要做这三件事:

  1. 去超市买菜
  2. 回家做饭
  3. 去取昨天的快递

如果在单线程的情况下,JVM,CPU指令集会对其进行优化,比如按照1 -> 3 -> 2的顺序执行,结果也没问题,还少出去一次,执行效率更高

编译器对于代码重排序的前提是 “保持逻辑不发生变化”。这一点在单线程环境下比较容易判断,但是在多线程环境下就没那么容易了,多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此代码重排序之后很容易导致优化后的逻辑和之前不等价

解决方案:synchronized

五、synchronized(同步的)关键字-监视器锁monitor lock

  “同步”这个词,用的很广泛,在多线程中指“互斥”,在文件IO和网络编程中,相对的词叫做“异步”,此处同步和互斥没有任何关系,表示的是消息的发送方,如何获取到结果

(一)synchronized的使用方式

  使用synchronized,本质上是对某个“对象”进行加锁,在Java中,每个类都是继承自Object,因此每个类都包含Object自带的属性和方法,在创建一个对象之后,每个对象分配的内存空间中,有一部分就保存了那些属性,称为对象头,可能程序猿用不到,但是JVM需要使用到,其中就包含了是否加锁的属性

1. 直接修饰普通的方法

直接修饰普通的方法对对象进行加锁时,指定的锁对象就是this
代码示例:

class Counter {
    private int count = 0;

    public int getCount() {
        return count;
    }

    public synchronized void countAdd() {
        count++;
    }
}

public class ThreadTest{
    static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for(int i = 0; i < 50000; i++) {
                    counter.countAdd();
                }
            }
        };
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for(int i = 0; i < 50000; i++) {
                    counter.countAdd();
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
    }
}

  上述代码中,t1和t2就是同时调用的countAdd方法,因此无论谁调用,都会对这个counter对象进行加锁操作

2. 修饰一个代码块

需要显式指定对哪个对象进行加锁(Java中的任意对象都可以作为锁对象)

public synchronized void countAdd() {
    synchronized (this) {
        count++;
    }
}


private Object object = new Object();
public synchronized void countAdd() {
    synchronized (object) {
        count++;
    }
}

  上述两种写法,第一种是直接设定对this进行加锁,第二种是我们在类里面专门创建一个Object类型的锁对象,对该锁对象进行加锁,也可以保证有序
  以上这种随手拿个对象都可以作为锁对象的用法,是Java中非常有特色的设定,其他语言没有,正常语言都是有专门的锁对象的

3. 修饰一个静态方法

相当于针对当前类的类对象加锁,即上述例子的Counter.class

(二)synchronized的特性

1.互斥

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

  • 进入 synchronized 修饰的代码块,相当于加锁
  • 退出 synchronized 修饰的代码块,相当于解锁

阻塞等待

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

  • 上一个线程解锁之后,另一个线程并不是立即就能获取到锁,而是要靠操作系统来“唤醒”。这也就是操作系统线程调度的一部分工作
  • 如果阻塞等待的有多个锁,当占有此锁的线程释放锁之后,其他的线程并不是按照先来后到的顺序来获取锁,而是重新竞争

2.刷新内存

synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存(即从内存中读取数据到CPU寄存器)
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存(写回内存)
  5. 释放互斥锁

3.可重入

死锁问题

简单举例:

public class ThreadTest{
    synchronized public void func() {
        synchronized (this) {
            
        }
    }
}

  如上图所示,外层锁加锁成功之后,该对象就被加锁,如果要进入里层代码块时,还要给该对象加锁,可是该对象已经被加锁,就要等待锁被释放才能继续加锁执行代码块,但是要想让锁释放,必须要让方法内部代码执行完毕,但是要想执行完内部代码,必须先让内部加锁,因此程序就僵住了

死锁的场景

  1. 一个线程,一把锁(上述情况)
  2. 两个线程。两把锁

当存在线程 a 和线程 b 都需要同时占用锁 x 和锁 y 来执行代码,但是如果 a 占用 x 的同时 b 占用了 y ,并且双方都在等对方解锁
l两个线程两把锁死锁问题

  1. N个线程,M把锁(经典哲学家就餐问题)

一个餐桌上有多个哲学家,每个哲学家两边都放着一根筷子,哲学家只做两件事情,思考和拿起两边的筷子吃饭,但是什么时候思考,什么时候拿筷子不确定,并且每个哲学家都是先拿起自己右手边的筷子,就可能出现死锁问题
多个线程多把锁问题
如上图,每个哲学家都拿起了自己右手的筷子,在拿左手的筷子时发现左手筷子已经被拿走了,因此就会一直等待,直到左手边的筷子放回,每个哲学家都是这么固执,就会导致死锁问题的出现

死锁的四个必要条件

  1. 互斥条件-一个锁被占用之后,其他线程占用不了
  2. 不可抢占-一个锁被占用之后,其他线程不能强制解除占用,必须等待
  3. 请求和保持-线程在已获得的资源未使用完时,会一直占有,除非任务执行完毕显式的释放
  4. 环路等待-等待成环了,比如上述哲学家就餐问题

避免死锁
  实际开发中,要想避免死锁,关键还是从第四个条件入手,只要约定好针对多把锁进行加锁时,遵循一定的顺序即可,比如,上述哲学家就餐问题,可以将每根筷子进行编号,要求每次拿筷子时,必须先拿编号小的筷子

多个线程多把锁问题解决

  不过实际开发中,很少出现锁套锁的问题,一旦出现,必须约定好加锁的顺序

synchronized的可重入性

  针对上述一个线程一把锁问题, Java 的 synchronized 就被设为可重入锁,可重入锁内部,会记录当前的锁是被哪个线程占用的,也会记录一个"加锁次数",线程a第一次加锁,能加锁成功,锁内部就记录了当前是a线程在占用,并且加锁次数 = 1,后续如果还是a对锁加锁,那么就单纯是把加锁次数置为2,后续解锁时,先把计数 -1,直到计数减为0,就真的解锁成功了

  可重入锁的意义就是降低了程序猿的负担(降低了使用成本,提高了开发效率),但是也带来了代价,程序中需要有更高的开销(维护锁属于哪个线程,并且加减次数,降低了运行效率),但是,总的来说,一定限度内,开发效率比运行效率更重要!!!

(三)Java标准库中的线程安全类

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

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

  有一些是线程安全的,使用了一些锁机制来控制,以下前四个在一些关键方法上都有 synchronized,有这个操作,就能保证在多线程环境下,修改同一个对象没问题,而String对象是不可变对象,即没有提供 public 的修改方法

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

六、volatile关键字

(一)volatile对于内存可见性

volatile会禁止编译器优化,能保证内存可见性

  1. 代码在写入 volatile 修饰的变量时
  • 改变线程工作内存(CPU寄存器)中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存(实际内存)
  1. 代码在读取 volatile 修饰的变量时
  • 从主内存中读取 volatile 变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

(二)volatile对指令重排序

volatile关键字禁止指令重排序有两层意思:

  1. 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  2. 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行

(三)volatile不保证原子性

volatile只是保存内存可见性,不能像synchronized一样保证原子性

七、wait(等待) 和 notify(通知)

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

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

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

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

(一)wait()方法

wait()方法的效果

  • 使当前执行代码的线程进行等待(把线程放到等待队列中)
  • 释放当前的锁对象
  • 满足一定条件时被唤醒,尝试重新获得之前的锁对象

wait要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常

代码示例:

//第一段代码,无synchronized
public class ThreadTest{
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait前");
        object.wait();
        System.out.println("wait后");
    }
}

//第二段代码
public class ThreadTest{
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("wait前");
            object.wait();
            System.out.println("wait后");    
        }
    }
}

第一段代码运行结果:
wait等待示例

第二段代码运行结果:
第二段代码运行结果
wait等待
  在第二段代码中,如果没有线程将他唤醒,他将会一直等待下去,因此就需要用到notify()方法

(二)notify()方法

notify方法是唤醒等待的线程

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

代码示例:使用notify()唤醒线程

public class ThreadTest{
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            @Override
            public void run() {
                synchronized (locker) {
                    System.out.println("t1 wait前");
                    try {
                        locker.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("t1 wait后");
                    System.out.println("t1 notify前");
                    locker.notify();
                    System.out.println("t1 notify后");
                }
            }
        };
        t1.start();

        Thread.sleep(1000);

        Thread t2 = new Thread() {
            @Override
            public void run() {
                synchronized (locker) {
                    System.out.println("t2 notify前");
                    locker.notify();
                    System.out.println("t2 notify后");
                    System.out.println("t2 wait前");
                    try {
                        locker.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("t2 wait后");
                }
            }
        };
        t2.start();
    }
}

运行结果:
wait和notify的练习

(三)notifyAll()方法

  notify()方法只是随机的唤醒某一个等待线程,使用notify()则使一次性唤醒所有正在等待的线程,然后所有线程抢占,谁先抢到算谁的

面试题:wait和sleep的对比

理论上这两者没有可比性,wait是用于线程间通信的,一个是让线程阻塞一段时间
相同的就是都可以让线程停止执行一段时间

  1. wait需要搭配 synchronized 使用,sleep不需要
  2. wait是Object的方法,sleep是Thread的静态方法

八、多线程案例

(一)单例模式

  设计模式相当于固定的套路,类似于棋谱,让程序猿可以按照套路来实现代码,保证代码不会太差
单例模式,保证某个类在程序中只存在唯一一份实例,而不会创建出多个实例。单例模式又分为两种模式,分别是饿汉模式和懒汉模式

饿汉模式

在类加载时就直接创建实例
如何保证单例模式
  在Java文件中类对象只有一份,在一个类中只定义一个静态成员变量,并且类中的构造方法也设为private,就可以保证唯一实例,而饿汉模式,则是在类加载时就创建静态实例
代码实例:

class Singleton {
    //1.创建一个static的成员变量并立即实例化
    private static Singleton instance = new Singleton();
    //2.将类的构造方法设为private,保证不会再创建其他的实例
    private Singleton() {}

    //创建一个public方法,让类外获得唯一的实例
    public static Singleton getInstance() {
        return instance;
    }
}

public class ThreadTest{
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
    }
}

懒汉模式(更推荐)

不着急创建实例,而是在用到的时候才进行创建

代码示例:

class Singleton2 {
    // 1.先创建一个 static 的成员,但先不进行实例化
    private static volatile Singleton2 instance;
    // 2.依然将构造方法设为private
    private Singleton2() {}

    // 2.创建一个 public 方法,当外面第一次调用该方法时实例化instance
    public static Singleton2 getInstance() {
        // 加锁之后,线程安全问题解决了,每次调用只会进行读取,不会创建
        // 1. 而每次线程调用都要加锁解锁和存在大量锁竞争,执行效率极低
        //    因此在外面再套一层 if条件
        //    保证除了刚开始多个线程同时调用时会存在锁竞争之外,以后判断发现不为null,就直接得到实例
        // 2.如果刚开始大量的线程调用getInstance方法,那由于此时instance都是null
        //   可能触发编译器优化,导致以后线程每次都是直接在工作内存中拿,因此加了if效果不大
        //   所以还需要将instance修饰为volatile
        if(instance == null) {
            synchronized (Singleton2.class) {
                //单例模式为了保证只有一个实例
                //在多线程环境下,就要保证实例只创建一次,需要对创建实例加上synchronized
                //而加锁的对象可以直接选择类对象
                if(instance == null) {
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }

}

public class ThreadTest{
    public static void main(String[] args) {
        Singleton2 singleton2 = Singleton2.getInstance();
    }
}

(二)阻塞式队列

1.概念

阻塞队列是一种特殊的队列,也遵循“先进先出”的原则
阻塞队列是一种线程安全的数据结构,并且具有以下特征:

  • 当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取元素
  • 当队列空的时候,继续出队列也会阻塞,直到有其他线程往队列中插入元素

阻塞队列的典型应用场景就是“生产者消费者模型

2.生产者消费者模型

  生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题。
  生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取

阻塞队列的优点:

  1. 阻塞队列相当于一个缓冲区,平衡了生产者和消费者的处理能力

  在某些特殊场景下,比如“双十一”,服务器收到大量请求,如果直接一股脑给了服务器,那很有可能直接挂掉,这个时候就可以先把这些请求放到阻塞队列中,再由消费者线程慢慢处理这些请求,简称“削峰
而如果过了“双十一”这个节点,之后可能有一段时间请求数量就很少,此时由于之前双十一在阻塞队列中还积攒了很多的请求,因此服务器仍然继续稳定的处理请求,不用太过担心无请求可处理的情况,简称“填谷

  1. 阻塞队列也能使生产者和消费者之间解耦合

加了阻塞队列之后,生产者和消费者都不用关心双方是谁,如何处理数据,怎么处理,可以由阻塞队列提供接口,双方都遵循这个接口即可,不至于说必须是哪个生产者或消费者,只要能发送请求或处理请求就够了

3.标准库里的阻塞队列

阻塞队列接口:BlockingQueue
代码示例:

public static void main(String[] args) throws InterruptedException {
    BlockingQueue<String> queue = new LinkedBlockingQueue<>();
    queue.put("hello");
    String s = queue.take();
    System.out.println(s);
}

  BlockingQueue继承了Queue接口,因此也是有offer(),poll(),peek()这些方法,但是实现这些方法的类不是按照线程安全的规则来实现的,因此主要还是用put()和take()方法

4,阻塞队列的模拟实现

代码示例:

class MyBlockingQueue {
    //保存数据
    private static Integer[] date = new Integer[20];
    //记录数据的个数
    private static int size = 0;
    //队列的队头位置
    private static int head = 0;
    //队列的队尾位置
    private static int tail = 0;
    private static Object locker = new Object();

    //入队列
    //由于整个方法一直在进行修改操作,因此给方法内的所有代码加上锁
    public void put(int value) throws InterruptedException {
        synchronized (locker) {
            if(size == date.length) {
                //队列满之后,应该等待,等待take唤醒
                locker.wait();
            }
            date[tail++] = value;
            size++;
            //判断 + 赋值操作 比 求余操作 对计算机来说更简单
            if(tail == date.length) {
                tail = 0;
            }
            //成功入队列一个元素之后,队列不为空,唤醒take
            locker.notify();
        }
    }

    //出队列
    //由于整个方法一直在进行修改操作,因此给方法内的所有代码加上锁
    public Integer take() throws InterruptedException {
        synchronized (locker) {
            if(size == 0) {
                //队列空了,应该阻塞,等待put唤醒
                locker.wait();
            }
            size--;
            int ret = date[head++];
            if(head == date.length) {
                head = 0;
            }
            //成功出队列一个元素,队列不为满,唤醒put
            locker.notify();
            return ret;
        }
    }

}

public class ThreadTest{
    public static MyBlockingQueue queue = new MyBlockingQueue();
    public static void main(String[] args) throws InterruptedException {
        //实现一个简单的生产者消费者模型
        Thread t1 = new Thread() {
            @Override
            public void run() {
                int i = 0;
                while(true) {
                    try {
                        System.out.print("生产了:" + i + " ");
                        queue.put(i++);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };

        Thread t2 = new Thread() {
            @Override
            public void run() {
                while(true) {
                    try {
                        Thread.sleep(1000);
                        Integer ret = queue.take();
                        System.out.println("消费了:" + ret);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        t1.start();
        t2.start();
    }
}

运行结果:
生产者消费者模型

(三)定时器

1.概念

类似于“闹钟”,达到一个设定的时间之后,就执行某个指定的代码

2.标准库中的定时器

  • 标准库中提供了一个Timer类,核心方法为 schedule
  • schedule包含两个参数。第一个参数指定即将要执行的任务代码,第二个参数指定多长时间之后执行(单位毫秒)

代码示例:

public static void main(String[] args) {
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("hello");
        }
    }, 1000);
    System.out.println("main");
}

运行结果:
计时器Timer测试
  我们发现执行完任务之后代码并没有停止,原因就是Timer内部有专门的线程,来负责执行注册的任务,所以还要等待其他的任务

3.定时器的模拟实现

定时器应该包括以下几个内容:

  • 一个带有优先级的阻塞队列(用来管理所有的任务和时间,并在到达所有任务中最近时间之后,执行该任务,这也是需要带优先级的原因)
  • 计时器中的队列里每个任务应该都要有一个组织形式,即定义一个task类来组织任务内容和执行时间
  • task类中应该包括一个任务属性,比如实现runnable这样的接口,再一个时间属性,由于需要存入堆中,因此需要实现Comparable接口
  • 在创建计时器实例时,应该有一个专门处理任务的线程,扫描队首元素,看队首元素是否需要执行
class TimerTask implements Comparable<TimerTask>{
    //任务中需要包括任务要求和时间
    //1.描述一个任务
    private Runnable runnable;
    //2.说明时间
    private long delay;

    //此处传入的时间应该是相对时间,而我们要将任务时间与系统时间比较的话,就要转成绝对时间
    public TimerTask(Runnable runnable, long time) {
        this.runnable = runnable;
        this.delay = time + System.currentTimeMillis();
    }

    public void run() {
        runnable.run();
    }

    public long getDelay() {
        return delay;
    }

    @Override
    public int compareTo(TimerTask o) {
        // 为什么这里实例 o 可以直接得到 delay 属性
        // 因为是在自己类中调用的本身的属性,this 和 o 并没有区别
        return (int) (this.delay - o.delay);
    }
}

class MyTimer {
    //首先应该创建一个管理任务的数据结构,时间最靠前的任务先执行
    //因此选择堆,并且要保证线程安全
    //由于采用了堆保存,并且我们应该按照时间维护一个小根堆,因此任务类必须实现Comparable接口
    private PriorityBlockingQueue<TimerTask> queue = new PriorityBlockingQueue<>();

    //提供一个可以传任务和时间的方法,并且把任务传到队列中
    public void schedule (Runnable runnable, long delay) {
        TimerTask task = new TimerTask(runnable, delay);
        queue.put(task);
        //每当插入了一个新的任务,都要进行唤醒操作
        synchronized (locker) {
            locker.notify();
        }
    }

    private static Object locker = new Object();

    // 创建一个MyTimer实例,那么就应该时刻等待任务执行
    // 因此在构造方法中,直接创建一个线程来执行到达时间的任务
    public MyTimer() {
        Thread thread = new Thread() {
            @Override
            public void run() {
                //此时如果没有任何操作,就这样一直while(true)不断的循环
                //是极其浪费资源的,尤其是CPU,称为”忙等“
                //为了避免这种情况,我们采用wait()方法
                //等时间到了或有新的任务插入进来,才进行唤醒
                while(true) {
                    try {
                        TimerTask task = queue.take();
                        long curTime = System.currentTimeMillis();
                        if(curTime < task.getDelay()) {
                            queue.put(task);
                            synchronized (locker) {
                                locker.wait(task.getDelay() - curTime);
                            }
                            continue;
                        }
                        task.run();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                }
            }
        };
        //不要忘记执行
        thread.start();
    }

}

public class ThreadTest{
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        }, 1000);
        System.out.println("main");
    }
}

(四)线程池

1.概念

  承接最开始,虽然线程比进程更加轻量,但是如果频繁的创建和销毁线程,也是一笔不小的开销,因此就有了线程池的概念,在开始的时候先创建一些线程备着,如果有任务了,那么就由这些线程执行,执行完之后,也不销毁线程,而是等待下一次任务来继续执行。这样在应用程序中创建很多线程只执行任务,不销毁的状态就称为线程池 线程池最大的好处就是减少每次创建、销毁线程的损耗
  为什么对线程池的操作比创建、销毁进程的更好?
  原因:我们写的代码,有的在应用程序这一层就能执行,这样的代码称为“用户态”运行的代码;有的需要在内核中运行的代码,称为“内核态”运行的代码,比如创建线程,就是需要内核的支持,t.start()就要进入内核态来运行,而对于线程池来说,放入池中或从池中取,就不涉及内核态,一般认为,纯用户态的操作,效率要比经过内核态处理的操作效率更高,因为在内核中,可能还有其他工作要做,什么时候能把我们想要的任务干了,不可控

2.标准库的线程池

(1)最标准的线程池类Class ThreadPoolExecutor
简单介绍一下构造方法中的参数,以参数最全的构造方法作为示例

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

以上的构造方法中共有以下几个参数

  1. int corePoolSize。核心线程数(正常情况下该有的线程数量)
  2. int maximumPoolSize。最大线程数(当需求暴增,核心线程处理不过来的时候,就需要在额外创建一些线程来共同处理,这个参数就表示了最多能有多少线程)
  3. long keepAliveTime。临时线程保留最长的时间(当需求峰值过去之后,保不齐可能不久又会有峰值,因此先让临时线程保留一段时间,等时间到了之后,再销毁)
  4. TimeUnit unit。临时线程保留时间的单位
  5. BlockingQueue<Runnable> workQueue。自定义一个任务队列,线程池提供一个 submit 方法来让程序猿把任务注册到线程池,加到任务队列中
  6. ThreadFactory threadFactory。线程工厂,线程是怎么创建出来的(给线程的创建指定一些属性和参数,不详细展开解释)
  7. RejectedExecutionHandler handler。描述拒绝策略,即当任务队列满了,该怎么做(忽略最新任务;阻塞等待;…)

以上参数中前两个参数最重要

如果使用线程池,那么线程数该如何约定?**

  答:应该通过性能测试的方式,找到合适的值,根据不同的线程数,观察程序处理任务的速度,和程序持有的CPU占用率。线程数多了,整体速度会变快,但是CPU占用率也会高。线程数少了,整体速度变慢,但是CPU占用率会下降。我们要做的就是,找到一个程序运行效率速度能接受,CPU占用率也合适的平衡点

(2)简化后的线程池类Executors
本质是对ThreadPoolExecutor进行了封装,提供一些默认参数

  • 使用 Executors.newFixedThreadPool(n) 能创建出固定包含 n 个线程的线程池
  • 返回值类型为 ExecutorService
  • 通过 ExecutorService.submit() 可以注册一个任务到线程池中

代码示例:

public static void main(String[] args) {
    //创建一个固定数量的线程池,参数就是线程个数
    ExecutorService executors = Executors.newFixedThreadPool(8);
    //创建一个自动扩容的线程池
    //Executors.newCachedThreadPool();
    //创建一个只有一个线程的线程池
    //Executors.newSingleThreadExecutor();
    //创建一个拥有定时器功能的线程池
    //Executors.newScheduledThreadPool();
    for (int i = 0; i < 10; i++) {
        executors.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        });
    }
}

3.线程池的模拟实现

模拟简化版本的线程池
代码示例:

class MyThreadPool {
    //创建一个线程池,应该具备的操作和功能
    //1.确定任务形式:可以直接使用Runnable
    //2.用一定的数据结构来组织任务,可以用阻塞队列
    //3.描述每个线程的工作(不断的从任务队列中拿任务并执行)
    //4.用一定的数据结构来组织线程,可以直接使用顺序表存
    //5.线程池有了,任务队列有了,就要允许程序猿向线程池注册任务进来,单独写一个public方法来传任务
    //6.最后任务传进来了,开始执行:构造方法,创建num个线程,把每个都启动,并把线程都存到线程池中

    //1.先确定任务形式
    private Runnable runnable;
    //2.规定组织任务的数据结构,直接使用阻塞队列
    private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();
    //3.创建线程,使用继承Thread的方式
    //  每个线程都干一件事,不断的从任务队列中取任务并执行
    private static class Work extends Thread {
        private BlockingDeque<Runnable> queue = null;
        public Work(BlockingDeque<Runnable> queue) {
            this.queue = queue;
        }
        @Override
        public void run() {
            while(true) {
                //由于外面私有成员任务队列取不到,因此我们向内部类的构造方法中传递这个队列
                try {
                    Runnable work = queue.take();
                    work.run();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
    //4.组织线程,使用线性表
    private List<Work> works = new LinkedList<>();

    //5.线程池有了,任务队列有了,接下来让程序猿传任务进来
    public void submit(Runnable runnable){
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    //6.任务已经准备好了,让线程池开始工作,直接放在构造方法中
    public MyThreadPool(int num) {
        for (int i = 0; i < num; i++) {
            //创建num个线程(每个线程需要任务列表的参数),并将这些线程启动,放在顺序表works里
            Work work = new Work(queue);
            work.start();
            works.add(work);
        }
    }

}

public class ThreadTest{
    public static void main(String[] args) {
        MyThreadPool pool = new MyThreadPool(8);
        for(int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }
}

总结

  以上就是多线程最基本的内容,也是以后可能在开发中最常用的一些操作,之后会再介绍一些虽然工作中不太常用,但是需要我们了解的多线程相关的底层知识,会用工具是工作中的基本要求,但是明白原理才能让我们变得更加强大,工具使用起来也会更加得心应手!!!
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

求索1024

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值