两万字带你掌握多线程

进程和线程

线程

因为计算机的发展,系统支持多任务了。所以就需要并发编程。通过多进程,是完全可以实现并发编程的,但是也有个问题:如果要频繁的创建/销毁进程,就需要分配内存,打开文件,就需要释放资源,释放内存。执行任务的成本较高,主要是因为资源的 创建 和 释放 不是高效的,所以成本较高。

实现并发编程中,解决 创建 和 销毁 消耗资源大的问题,有两个方法:

  1. 进程池:进程池可以解决问题,提高效率。但是也有问题,池子里的闲置进程,不使用的时候也在消耗系统资源,消耗的系统资源太多了。
  2. 使用线程来实现并发编程:线程比进程更轻量,每个进程可以执行一个任务,每个进程也能执行一个任务(执行一段代码),也能够并发编程,创建线程的成本比创建进程要低很多,销毁线程的成本也比销毁进程低很多,调度线程的成本也比调度进程低很多。
  3. 但是线程不是越多越好,如果线程多了,这些线程可能要竞争同一个资源,这个时候,整体的速度就受到了限制,因为整体硬件资源是有限的。

线程和进程的区别和联系

  1. 进程包含线程:一个进程里可以有一个线程,也可以有多个线程。
  2. 进程和线程都是为了处理并发编程这样的场景。但是进程有问题,频繁的创建和释放的时候效率很低,相比之下,线程更轻量,创建和释放的效率更高。因为线程少了申请和释放的过程
  3. 操作系统创建进程,要给进程分配资源,进程是操作系统分配资源的基本单位。操作系统创建的线程,是要在 CPU 上调度执行,线程是操作系统调度执行的基本单位。
  4. 进程具有独立性,每个进程有各自的虚拟地址空间,一个进程挂了,不会影响到其它进程。同一个进程中的多个线程,公用同一个内存空间,一个线程挂了,可能影响到其他线程,甚至导致程序崩溃。
  5. 线程比进程轻量的原因:进程重量重在资源的申请释放。线程是包含在进程当中的,一个进程中的多个线程共用同一份资源。只是创建第一个进程的时候(由于要分配资源),成本是相对高的,后续在这个进程中再创建其他线程,这个时候成本就要更低一些,因为不必再分配资源了。
  6. 可以把进程比作一个工厂,线程就是生产线,线程多了之后,生产效率就高了,如果再建一个工厂来生产,效率也可以变高,但是资源花费大,所以通过增加一条生产线(线程)来提高效率的话,资源花费就很小。

并发编程

Java 中并发编程主要用多线程,不同于其他语言。go 语言是通过多协程来实现,erlang 是通过 actor 模型实现并发, js 是通过定时器 + 实际回调的方式实现并发。

Java 进行多线程编程

Java 提供了一个 Thread 类,来表示/操作线程。Thread 类也可以视为是 Java 标准库提供的 API。创建好的 Thread 实例,其实和操作系统中的线程是一一对应的关系。操作系统提供了一组关于线程的 API(不过是 C/C++ 写的),java 进一步封装,就成了 Thread 类。

最基本的多线程代码

通过 Tread 来创建,不过这里是创建一个自己的 MyTread 并重写 run 方法来看线程的情况。run 方法就描述了线程内部要执行什么代码代码如下:

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

run 方法里面描述了线程内部要执行哪些代码,每个线程都是并发执行的(各自执行各自的代码,就是告诉线程,要执行的代码是什么)。不是一定义这个类,一写 run 方法,线程就创建出来,相当于有活了,但是还没干。调用 new 的对象的 start 方法,才是真正的创建了线程。这里可以创建很多个线程,这些线程都是同一个进程内部创建的。运行结果如下:
在这里插入图片描述

最简单的并发编程代码

一个进程中,至少会有一个线程,在一个 Java 进程中,至少会有一个调用 main 方法的线程,自己创建的 t 线程,和自动创建的 main 线程,就是并发执行的关系(宏观上看起来是同时执行)。这里的代码就是 MyThread 和 main 一起并发执行。代码如下:

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

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

这里的结果就是 Thread!和 Main 交替输出,每次输出和上次输出差不多相隔一秒:在这里插入图片描述
在阻塞一秒之后,先唤醒 Thread 还是 Main,是不确定的。对于操作系统来说,内部对于线程之间的调度顺序,在宏观上可以认为是随机的(抢占式执行)。

Runnable 接口

Runnable 就是在描述一个任务,然后重写 run 方法,就是要执行的任务内容。然后通过 Runnable 把描述好的任务交给 Thread 实例:

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

运行结果如下:
在这里插入图片描述

通过匿名内部类

匿名继承 Thread 类

通过匿名内部类也可以实现 :

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

这里的匿名内部类是继承自 Thread 类,同时重写了 run 方法,同时再 new 出这个匿名内部类的实例。运行结果如下:
在这里插入图片描述

匿名 Runnable

和匿名 Thread 一样,也可以实现 Runnable 接口:

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

这里 new 的 Runnable 针对这个创建的匿名内部类,同时 new 出的 Runnable 实例传给 Thread 的构造方法。

Thread 和 Runnable 选择

通常认为选择 Runnable 来写更好一些,能够做到让线程和线程执行的任务,更好的解耦。写代码注重 高内聚,低耦合。Runnable 单纯的只是描述了一个任务,至于这个任务是要通过一个进程来执行,还是线程池来执行,还是协程来执行,Runnable 并不关心,Runnable 里面的代码也不关心。

lambda 表达式

在使用多线程的时候,也可以写成 lambda 表达式,这种表达式方法更简单:

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

多线程对时间的优化

我们来计算两个变量的自增,从 0 自增到 10亿,然后对比时间。一种是串行执行,一种是并发执行。不过要注意的是,多线程当中的时间戳代码是在 main 线程中,所以要等到 t1 和 t2 都执行玩然后再计时。所以通过 join(); 方法来等待计时结束,代码如下:

public static void serial() {
    long beg = System.currentTimeMillis();
    long a = 0;
    for (int i = 0; i < 10_0000_0000; i++) {
        a++;
    }
    long b = 0;
    for (int i = 0; i < 10_0000_0000; i++) {
        b++;
    }
    long end = System.currentTimeMillis();
    System.out.print((end-beg)+"ms");
}
public static void concurrency() throws InterruptedException {
    long beg = System.currentTimeMillis();
    Thread t1 = new Thread(()->{
        long a = 0;
        for (int i = 0; i < 10_0000_0000; i++) {
            a++;
        }
    });
    t1.start();
    Thread t2 = new Thread(()->{
        long b = 0;
        for (int i = 0; i < 10_0000_0000; i++) {
            b++;
        }
    });
    t2.start();
    t1.join();
    t2.join();
    long end = System.currentTimeMillis();
    System.out.print((end-beg)+"ms");
}
public static void main(String[] args) throws InterruptedException {
    serial();
    System.out.println();
    concurrency();
}

运行结果如下:
在这里插入图片描述
可以看出,多线程的运行效率确实比串行要高,不过如果数很小的时候,就不适合用多线程了,因为创建线程也需要时间,如果很小的话,用串行就够了。多线程适用于 CPU 密集型的程序,程序要进行大量的计算,使用多线程就可以更充分的利用 CPU 的多核资源。

Thread 类的属性和方法

Thread(String name) 创建线程对象并命名

在创建完 Thread 对象之后,可以对其进行命名。命名之后在调试的时候很方便:

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

    Thread t2 = new Thread(()-> {
        while (true) {
            System.out.println("Thread t2");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    },"Thread t2");
    t2.start();
}

运行结果如下:
在这里插入图片描述

使用 jconsole 来查看 Java 进程

在对进程命名之后,就可以从 jconsole 来查看进程了。

  • 从 JDK 的 bin 目录中找到 jconsole :
    在这里插入图片描述
  • 然后打开选择运行的进程
    在这里插入图片描述
  • 可能会弹出不安全的连接,继续连接就好
    在这里插入图片描述
  • 然后点线程
    在这里插入图片描述
  • 然后就能看到自己命名的线程了,很方便调试
    在这里插入图片描述
  • 显示线程的代码执行到哪里了
    在这里插入图片描述

是否后台线程 isDaemon

如果线程是后台消息称,就不影响进程退出。如果线程不是后台线程(前台线程),就会影响到进程退出。就像创建的 t1 和 t2:

  • 如果都是前台线程,即使 main 方法执行完毕,进程也不能退出,得等 t1 和 t2 都执行完,整个进程才能退出。
  • 如果都是后台线程,此时如果 main 执行完毕,这进程就直接退出,t1 和 t2 就被强行终止了。

是否存活 isAlive

Thread t 对象的生命周期和内核中对于的线程,生命周期并不完全一致。创建出对象之后,在调用 start 之前,系统当中是没有对应线程的,在 run 方法执行完了之后,系统当中的线程就销毁了。但是 t 这个对象可能还存在。通过 isAlive 就能判断当前系统的线程的运行情况。

start

start 决定了系统中是不是真的创建出线程,run 只是一个普通的方法,描述了任务的内容。代码如下:

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

运行结果如下:
在这里插入图片描述
那么把 start 换成 run :

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

运行结果如下:
在这里插入图片描述
发现 run 也能输出 Thread 。但关键是 run 并没有创建线程,这里的 run 是输出了任务的内容,而不是创建线程。Thread 则是在操作系统当中创建线程。下面用一个更简单理解的代码来演示:

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

运行结果如下:
在这里插入图片描述
因为 start 是创建线程,所以会和 main 线程并发执行。如果换成 run 的话:

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

运行结果如下:
在这里插入图片描述
只输出了任务内容,没有创建线程,只是从上往下执行。

中断线程

就是让线程停下来,线程停下来的管,是要让线程对应的 run 方法执行完。(还有一个特殊情况:是 main 这个线程,对于 main 来说,得是 main 方法执行完,线程就完了)

设置自定义标志位

通过手动设置一个标志位,来控制线程是否要执行结束。代码如下:

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

运行结果如下:
在这里插入图片描述
因为多个线程共同用一个虚拟地址空间,因此 main 线程修改的 isQuit 和 t 线程判断的 isQuit 是同一个值。

使用 Thread 内置的标志位

  1. Thread.interrupted() 这是一个静态方法
  2. Thread.currentThread().isInterrupted() 这是实例方法,其中 currentThread 能够获取到当前线程的实例。一般使用这个。

在主线程中,调用 interrupt 方法,来中断这个线程。代码如下:

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

        e.printStackTrace();
    }
    t.interrupt();
}

运行结果如下:
在这里插入图片描述
调用 interrupt(); 方法,可能慧出现两种情况:

  1. 如果线程是处在就绪状态,就是设置线程的标志位为 true
  2. 如果 t 线程处在阻塞状态(sleep 休眠了) 就会触发一个 interruptException,因为 sleep 阻塞了,所以此时设置标志位就不能起到及时唤醒的状态,就会打断 sleep,导致线程从阻塞状态被唤醒,从而继续执行代码,所以我们用 break 来跳出循环。

线程等待 join

多个线程之间,调度顺序不确定。线程之间的执行是按照调度器来安排的,这个过程可能是无序,随机的。线程等待,就是其中一种,控制线程执行顺序的手段,主要是控制线程结束的先后顺序。

调用 join 的时候,哪个线程调用 join 哪个线程就会阻塞等待,等到对应线程的 join 执行完毕为止(对应线程的 run 执行完)。

在主线程当中使用一个等待操作,来等待 t 线程执行结束。调用这个方法的线程,是 main 线程。针对 t 这个线程对象调用的。此时就是让 main 等待 t。调用 join 之后,main 线程就会进入阻塞状态(暂时无法在 CPU 上执行)。

代码执行到 join 这一行,就暂时停下了,不继续往下执行了。等到 t 的 run 方法跑完之后,join 就能继续往下走了,恢复成就绪状态。

但是 join 默认情况下,是死等(不见不散)。所以 join 提供了另外一个版本,可以执行等待时间,最长等多久,等不到就撤了。就是在 join(时间)

代码如下:

public static void main(String[] args) {
    Thread t = new Thread(()-> {
        for (int i = 0; i < 5; i++) {
            System.out.println("Thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.start();
            try {
        t.join(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

运行结果如下:
在这里插入图片描述

获取当前线程的引用

通过 currentThread() 的 getName() 方法 ,不过要注意的是,哪个线程调用这个方法,获取到的就是哪个线程的引用。

Thread 自带的 getName

代码如下:

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

这里事通过 Thread 的方式来创建线程。此时在 run 方法当中,直接通过 this 拿到的就是 Thread 的实例。运行结果如下:
在这里插入图片描述
没指定名字,默认是 0。

Runnable 实现

如果是 Runnable 的话,就不能用 this.getName 了,因为 Runnable 是一个单纯的任务,没有 name 属性。会直接抛出受查异常。所以只能用 Thread.currentThread().getName() ,如果是 lambda 表达式,也是这样。代码如下:

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

运行结果如下:
在这里插入图片描述

线程休眠 sleep

  1. 进程是通过 PCB 描述的,通过 双向链表组织的,但这个说法是针对只有一个线程的进程。
  2. 如果一个进程有多个线程,所以对应的就是一组 PCB 了。
  3. PCB 上有一个字段 tgroupld 这个 id 就相当于进程的 id,同一个进程当中的若干线程的 tgroupld 是相同的。

流程如下图所示:
在这里插入图片描述

进程的状态

就绪和阻塞。但是在 Java 的 Thread 类中,对于线程的状态,又进一步细化了。细化之后更方便查看问题出在哪里。

Java 当中细化的进程状态

  1. NEW:安排了工作, 还未开始行动。就是把 Thread 对象创建好了,但是还没有调用 start,也就是还没有创建线程。
  2. TERMINATED:工作完成了.操作系统当中的线程以及执行完毕销毁了,但是 Thread 对象还在。
  3. RUNNABLE:可工作的. 又可以分成正在工作中和即将开始工作。就是就绪状态,处于这个状态的线程,就是在就绪队列中,随时可以被调度到 CPU 上。如果代码当中没有进行 sleep 和其它可能导致阻塞的操作。代码大概率是处于 RUNNABLE 状态的。
  4. TIMED_WAITING:代码当中调用了 sleep 就会进入 TIMED_WAITING。或者 join 后面加上 超时时间,也会进入 TIMED_WAITING 状态,就是当前线程在一段时间之内,是阻塞状态。
  5. BLOCKED:表示当前线程在等待锁,导致了阻塞(阻塞状态之一)加锁的时候用 synchronized 。
  6. WAITING:当前状态在等待唤醒,导致了阻塞(阻塞状态之一)。

线程运行状态流程: NEW -> start -> RUNNABLE -> run 方法执行完 -> TERMINATED。在 RUNNABLE 的时候可以通过 sleep 来达到 TIMED_WAITING 或者通过 加锁 来达到 BLOCKED,或者通过 wait 来达到 WAITING 效果。

查看线程状态 getState

一些关键线程阻塞,就会出现卡死的情况。分析卡死原因的时候,第一步先看看线程所处的状态,看了状态之后分析程序出现问题的原因。代码如下:

public static void main(String[] args) {
    Thread t = new Thread(()-> {
        for (int i = 0; i < 5; i++) {
            System.out.println("Thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    System.out.println(t.getState());
}

运行结果如下:
在这里插入图片描述

线程安全

线程安全是线程当中最重要,最复杂的问题。多进程是最基本的处理并发编程的任务。有很多模型:actor csp async+await。操作系统调用线程的时候,是随机的(抢占式执行),因为是抢占式的,所以可能出现 bug 如果调度随机性,引入了 bug,那么就认为代码线程是不安全的。代码示例:

class Counter {
    public static int count;
    public void increase() {
        count++;
    }
}
public class Test2 {
    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.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

在代码中,用 count 作为两个线程自增的变量。运行多次,结果如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
运行多次之后发现结果总是不能达到 100000,就说明程序有 bug,两个程序是并发执行的,如果两个线程同时自增,就只加了 1。

count++ 其实是 三个 CPU指令

  1. 把内存当中的 count 值,加载到 CPU 寄存器当中。
  2. 把寄存器当中的值 + 1。
  3. 把寄存器的值写回到 内存的 count 当中。

因为是抢占式执行,就导致两个线程同时执行这三个指令的时候,就充满了随机性。就可能出现一组先后排列的情况,也就是不能都完成加 1 。

就像下面这种情况:
在这里插入图片描述
通过 load 把 count 加载到寄存器当中,两个寄存器读到的 count 值都是 1。然后 add 之后的值也都是 1。
在这里插入图片描述
也就是最后写会内存的值都是 1。就相当于少加了一次。
或者像下面着这情况:
在这里插入图片描述
仍然是少加了一次。

这里加的结果是在 50000 - 100000 之间。因为有一部分是串行的,有一部分是交错的。所以,如果能让 t1 先执行完,然后再让 t2 执行,就可以解决这样的问题了。

通过加锁来保证线程安全

像上面这种情况,就可以通过加锁来解决,我们这里使用 synchronized 来对 count++ 加锁,因为问题是出在 count++ 这里,所以我们对 count++ 加锁就好了。也就是在自增之前先加锁,自增之后解锁。解锁之后再执行另外一个线程。加锁之后,并发程度降低,数据更安全了,但是速度慢了。并发性越高,速度越快,但是可能会出现一些问题,就像这里的 count++ 。实际开发当中,一个线程要做的事很多。可能只有某一个步骤有线程安全,所以只对有线程安全的加锁就好了。代码如下:

class Counter {
    public static int count;
    synchronized public void increase() {
        count++;
    }
}
public class Test2 {
    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.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行结果如下:
在这里插入图片描述
通过加锁之后,就保障了数据的安全。

产生线程不安全的原因

  1. 线程是抢占式执行,线程间的调度充满随机性,是线程不安全的万恶之源。
  2. 多个线程对同一个变量进行操作。
  3. 针对变量的操作不是原子性的,也会导致线程不安全。
  4. 内存可见性,也会影响到线程安全。例如:针对同一个变量,一个线程进行读操作(循环进行很多次),一个线程进行修改操作(合适的时候执行一次)。读内存比读寄存器慢很多,循环一直去读的话,消耗就会很多,因此,频繁的读内存的值,就会非常低效,而且修改的线程迟迟不修改,读到的值一直是一样的值。所以,读的时候,就可能不从内存读数据了,而是直接从寄存器里面读。如果此时 把值修改了,那么就读不到这个值了。
  5. 指令重排序:也是编译器优化的一种操作。就是调整代码的执行顺序,执行效果不变,但是效率就提高了。调整的前提也是逻辑不变。代码是单线程的话,一般不会出问题,如果是多线程的话,就可能出现问题,避免问题还是通过 synchronized 加锁来操作。

内存可见性导致的线程不安全

在出现内存可见性问题的时候,当改变值的时候,线程并不会读到值的改变。代码示例:

private static int isQuit = 0;
public static void main(String[] args) {
    Thread t = new Thread(()->{
        while (isQuit == 0) {

        }
        System.out.println("循环结束,t 线程退出");
    });
    t.start();
    Scanner scanner = new Scanner(System.in);
    System.out.println("请输入一个 isQuit 的值:");
    isQuit = scanner.nextInt();
    System.out.println("main 线程执行完毕");
}

运行结果如下:
在这里插入图片描述
通过图片可以看到,当输入值之后,已经不满足线程执行的条件了,但是线程并没有停止,就是因为内存可见性的原因,导致线程还在运行。如果在线程当中加入 sleep 的话,就不会有这个问题了。

解决内存可见性的方法

  1. 使用 synchronized 关键字加锁。不光保证原子性,还能保证内存可见性。被 synchronized 包裹起来的代码,编译器就不敢轻易的做出上面优化的那种操作。
  2. 使用 volatile 关键字,volatile 和原子性无关,但是能保证内存可见性。就会禁止编译器做出优化,使得编译器每次判断的时候,都会重新从内存当中读取 isQuit 的值。

代码如下:

private static volatile int isQuit = 0;
public static void main(String[] args) {
    Thread t = new Thread(()->{
        while (isQuit == 0) {

        }
        System.out.println("循环结束,t 线程退出");
    });
    t.start();
    Scanner scanner = new Scanner(System.in);
    System.out.println("请输入一个 isQuit 的值:");
    isQuit = scanner.nextInt();
    System.out.println("main 线程执行完毕");
}

运行结果如下:
在这里插入图片描述

指令重排序

指令重排序也会影响到线程安全问题,也是编译器优化的一种操作。举例:去超市买东西:

在这里插入图片描述
如果按照菜单顺序买菜的话,就会绕很多路,浪费很多时间。如果重新排序之后再买的话,就是下面这种情况:
在这里插入图片描述
就会节省很多时间,这就是指令重排序带来的优化。不过在有些时候,写的功能很多的情况下,指令重排序也会导致程序,出现 bug。所以通过 synchronized 来解决这种问题。

synchronized 使用

synchronized 是同步的意思。不同的环境中,有不同的含义:

  1. 多线程中,线程安全中,同步 指的是“互斥”,一个进行的时候,另外一个就不能进行了。
  2. 在 IO 或者 网络编程 中,同步 相对的词叫做“异步” 此处的同步和互斥没有任何关系,和线程也没有关系,表示的是消息的发送方如何获取到结果。

直接修饰使用方法

使用 synchronized 直接修饰普通方法。本质是对某个对象进行加锁。在 Java 当中,每个类都是继承自 Object 。每个 new 出来的实例,里面一方面包含了自己安排的属性,一方面包含了“对象头”,对象的一些元数据。此时的锁对象就是 this,如下图所示:
在这里插入图片描述
当两个线程同时尝试对同一个对象加锁的时候,才有竞争,如果是两个线程在针对两个不同对象加锁,就没有竞争。

修饰代码块

使用 synchronized 修饰一个代码块。需要显示指定针对哪个对象加锁(Java 中的任意对象都可以作为锁的对象)。代码如下:

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

修饰一个静态方法

使用 synchronized 修饰一个静态方法。相当于针对当前的类对象加锁,也就是反射。把 synchronized 修饰到 static 方法上:
在这里插入图片描述
就相当于是下面这种情况:
在这里插入图片描述
就是针对类对象加锁。

监视器锁 monitor lock

synchronized 最原始的意义:

  1. 互斥
  2. 刷新内存
  3. 可重入:同一个线程针对同一个锁,连续加锁两次。如果出现了死锁,就是不可重入。如果不会死锁,就是可重入。

加两层锁

就是外层先加了一次锁,然后里层再对对象加一次锁。代码示例:

synchronized public void increase() {
    synchronized (this) {
        count++;
    }
}
  • 外层锁:进入方法,则开始加锁,这次能够加锁成功,因为当前锁没有人占用。
  • 里层锁:进入代码块,开始加锁,这次加锁不能加锁成功,因为这个锁被外层占用了,要等到外层锁释放,里层锁才能加锁。
  • 外层锁要执行完整个方法,才能释放。但是要想执行完整个方法,就得让里层锁加锁成功继续往下走。所以就变成死锁了。

为了防止出现这种情况,JVM 就实现了可重入锁,就是发生这种操作的的时候,不会死锁。就是可重入锁内部,会记录当前的锁被哪个线程占用,同时也会记录一个加锁次数。线程 a 针对锁第一次加锁的时候,是可以加锁成功的。锁内部就记录了当前的占用着的是 a,加锁次数是 1。后续再 a 对锁进行加锁,此时就不是真加锁,而是单纯的把计数器自增,加锁次数为 2。然后在解锁的时候,先把计数进行 -1,当锁的计数减到 0 的时候,就真的解锁。可重入锁的意义就是:降低了程序员的负担(降低了使用成本,提高了开发效率),但也带来了代价,程序中需要又更高的开销(维护锁属于哪个线程,并且加减计数,降低了运行效率)。

死锁的其它场景

  1. 一个线程,一把锁。
  2. 两个线程,两把锁。你要锁,他也要锁,就互相要,然后就死锁了。
  3. N 个线程,M 把锁。经典问题就是:哲学家就餐问题。

哲学家就餐问题:如下图:
在这里插入图片描述
每个哲学家都很固执,在想要吃饭的时候,如果筷子被别人占用,就会死等下去。所以,如果五个人同时拿起左手边的筷子,就陷入死锁了。
在这里插入图片描述
每个人都能拿起左手的筷子,每个人都拿不起右手的筷子。

死锁的四个必要条件

  1. 互斥使用:一个锁被一个锁占用之后,其他线程占用不了(锁的本质,保证原子性)。
  2. 不可抢占:一个锁被一个线程占用之后,其他线程不能把这个锁给抢走(挖墙脚不行)。
  3. 请求和保持:当一个线程占据了多把锁之后,除非四显示的释放锁,否则这些锁是在都是被该线程持有的。
  4. 环路等待:等待关系,成环了:A 等 B,B 等 C,C 又等 A。避免环路等待:约定好,针对多把锁加锁的时候,有固定的顺序就好。所有的线程都遵守同样的规则顺序,就不会出现环路等待。

Java 标准库当中的类

线程安全的部分:

  1. Vector (不推荐使用)
  2. HashTable (不推荐使用)
  3. ConcurrentHashMap
  4. StringBuffer
  5. String

线程不安全的部分:

  1. ArrayList
  2. LinkedList
  3. HashMap
  4. TreeMap
  5. HashSet
  6. TreeSet
  7. StringBuilder

在多线程当中,线程不安全的类要谨慎使用。

volatile 与 synchronized

volatile 只保证内存可见性,不保证原子性。禁止编译器优化,保证内存可见性。

如果无脑用 synchronized 的话,容易导致线程阻塞,一旦线程阻塞(放弃CPU),下次回到 CPU,这个时间就不可控了,如果调度不回来,自然对任意的任务执行时间就拖慢了。一旦代码当中使用了 synchronized ,这个代码大概率就和 高性能 无缘了。

volatile 就不会引起线程阻塞。

wait 和 notify

wait 和 notify 。就是等待和通知。是处理线程调度随机性的问题的,不喜欢随机性,需要让彼此之间有个固定的顺序。join 也是一种控制顺序的方式,更倾向于控制线程结束。
调用 wait 方法就会陷入阻塞。阻塞到有其他线程通过 notify 来通知。 wait 内部会做三件事:1、先释放锁 2、等待其他线程的通知 3、收到通知之后,重新获取锁,并继续往下执行。因此,想用 wait/notify 就得搭配 synchronized。代码如下:

public static void main1(String[] args) throws InterruptedException {
    Object object = new Object();
    //wait 哪个对象,就得针对哪个对象加锁。
    synchronized (object) {
        System.out.println("wait 前");
        object.wait();
        System.out.println("wait 后");
    }
}

运行结果如下:
在这里插入图片描述
可以看到在 wait 之后,程序就陷入了 WAITING 状态。如图:
在这里插入图片描述
就是由 wait 引起的阻塞。

搭配举例

在第一个线程 wait 之后,就可以通过第二个线程的 notify 来唤醒第一个线程。代码如下:

public static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        synchronized (locker) {
            System.out.println("wait 之前");
            try {
                locker.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("wait 之后");
        }
    });
    t1.start();
    Thread.sleep(3000);
    Thread t2 = new Thread(() -> {
        synchronized (locker) {
            System.out.println("notify 之前");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            locker.notify();
            System.out.println("notify 之后");
        }
    });
    t2.start();
}

然后代码当中就是 wait 三秒之后,进入线程二,然后打印出 notify 之后,再等待 3秒,然后使用 notify 唤醒。运行结果如下:
在这里插入图片描述
然后唤醒:
在这里插入图片描述
假设有两个线程 线程 t1:a b c d。 线程 t2:e f g h。我们需要让两个线程按照:a->e,b->f,c->g,d->h 顺序执行。就通过 wait 和 notify 如下图:
在这里插入图片描述
在执行完 a 之后进入 wait,然后执行完 e 之后,进行 notify,以此类推,就可以实现按顺序执行了。

notifyAll

假如有一个对象 o 有 10 个线程,都调用了 o.wait 此时 10 个线程都是阻塞状态。如果调用了 o.notify 就会把 10 个当中的一个给唤醒(唤醒哪个不确定),使用 notifyAll 就会把所有的 10 个线程都给唤醒。wait 唤醒之后,就会重新尝试获取到锁(这个过程就会发生竞争)。

  • 11
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Lockey-s

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

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

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

打赏作者

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

抵扣说明:

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

余额充值