讨伐Java多线程与高并发——多线程基础篇

本文是学习Java多线程与高并发知识时做的笔记。

这部分内容比较多,按照内容分为5个部分:

  1. 多线程基础篇
  2. JUC篇
  3. 同步容器和并发容器篇
  4. 线程池篇
  5. MQ篇

本篇为多线程基础篇。

目录

1 什么是线程?

2 线程的状态

3 线程的创建

4 线程同步

5 synchronized

5.1 Java对象的结构

5.2 synchronized的锁升级过程

5.2.1 偏向锁

5.2.2 轻量级锁(自旋锁)

5.2.3 重量级锁

6 volatile

6.1 volatile保证线程可见性

6.1.1 为什么要保证线程可见性?

6.1.2 volatile是怎么保证线程可见性的?

6.2 volatile禁止指令重排序

7 线程间通信

7.1 生产者和消费者问题

7.2 wait和notify

7.3 while轮询


1 什么是线程?

线程是操作系统进行运算调度的基本单位。

计算机的一切信息处理都是由CPU来最终完成的,每个(单核)CPU只能同时处理一个线程的请求。

操作系统是CPU的经纪人,操作系统对所有的应用程序说:“我们的CPU只能同时处理一个线程的请求,你们把你们的进程以线程为单位拆分好再过来。”

等应用程序把自己的进程拆分成一个一个线程后,操作系统提供一些内核线程和应用程序的线程一一对接,然后请CPU来处理这些内核线程的请求。

如图:

一个运行中的应用程序对应一个进程,一个进程对应多个线程。

线程的状态

一般来讲,线程共有5种状态:

  • 新建状态(New):线程对象被创建后,进入新建状态。
  • 就绪状态(Runnable):新建线程对象启动后,进入就绪状态。就绪状态的线程随时可能被CPU调度执行。
  • 运行状态(Running):线程被CPU调度执行。需要注意的是,线程只能从就绪状态进入运行状态。
  • 阻塞状态(Blocked):线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会继续运行。
  • 死亡状态(Dead):线程执行完成或因异常退出时,该线程结束生命周期。

在Java实现线程时,将线程分为6种状态:

  • NEW(新建):线程对象被创建后,进入新建状态。
  • RUNNABLE(运行):Java中将线程的就绪状态和运行状态统一为RUNNABLE状态。
  • BLOCKED(阻塞):线程暂时停止运行,等待获得锁资源。
  • WAITING(等待):等待其它线程做出特定动作(通知或中断)。
  • TIMED_WAITING(超时等待):等待其它线程做出特定动作(通知或中断),或者在指定时间后转为就绪状态。
  • TERMINATED(终止):线程执行完毕。

3 线程的创建

在Java中,创建线程有两种最基本的方式:

  • 继承Thread类
  • 实现Runnable接口
public class CreateThread{
    static class MyThread extends Thread{ //继承Thread类
        @Override
        public void run(){ //重写run()方法
            System.out.println("Hello MyThread!");
        }
    }
 
    static class MyRunnable implements Runnable{ //实现Runnable接口
        @Override
        public void run(){ //重写run()方法
            System.out.println("Hello MyRunnable!");
        }
    }
 
    public static void main(String[] args){
        new MyThread().start(); //调用start()方法启动线程
        new Thread(new MyRunnable()).start(); //调用start()方法启动线程
    }
}

在java.util.concurrent中提供了一个Callable接口,实现Callable接口创建线程可以有一个返回值:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class CreateThread {
    static class MyCallable implements Callable<Integer> { //泛型规定返回值类型
        @Override
        public Integer call() throws Exception { //重写call()方法,类似于Runnable接口中的run()方法
            System.out.println("Hello MyCallable");
            return 1024;
        }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyCallable myCallable = new MyCallable();
        FutureTask futureTask = new FutureTask(myCallable); //适配器模式
        new Thread(futureTask).start(); //调用start()方法启动线程
        //打印返回值
        Integer result = (Integer) futureTask.get();
        System.out.println(result);
    }
}

另外,使用Lambda表达式也可以创建线程。

public class CreateThread{
    public static void main(String[] args){
        new Thread(()->{
            System.out.println("Hello Lambda!");
        }).start();
    }
}

Thread类中的常用方法:

sleep():休眠,使线程进入阻塞状态,给其它线程让出执行机会,等休眠时间结束后,线程进入就绪状态和其它线程竞争CPU资源。

yield():礼让,使线程进入就绪状态,和其它线程竞争CPU资源。

join():合并,当前线程阻塞,先将join的线程执行完,再继续执行当前线程。可以用来保证线程之间的顺序执行。

4 线程同步

线程同步:当有一个线程在对内存进行操作时,其它线程不可以对这个内存地址进行操作,直到该线程操作完成。该线程操作完成前,其它线程处于等待状态。

Java中使用synchronized关键字来实现线程同步。

synchronized关键字的作用是给对象上锁,一把锁在任一时刻只能被一个线程持有。

提问:synchronized是给对象上锁还是给代码上锁?

答:给对象上锁。例如在下面的代码段中,正确的表达是,synchronized给o上锁,线程在拿到o之后可以执行大括号{ }内的代码。

public class Test{
    private int count = 10;
    private Object o = new Object();
    public void test(){
        synchronized(o){
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}

因为每次都要创建新的Object对象比较麻烦,所以上面的代码段可以改为:

public class Test{
    private int count = 10;
    public synchronized void test(){
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }
}

在上面的程序中,synchronized上锁的对象是this。

如果synchronized修饰的方法是静态方法(static),那么上锁的对象就是Test.class。

synchronized

5.1 Java对象的结构

在讲synchronized的底层实现之前,需要了解Java对象的结构。

一个Java对象在堆中占用16个字节(除了数组对象),其中:

前8个字节叫markword,markword里面存着锁信息、hashcode、GC信息等;

在Hotspot(一种常用的JVM)中,对象的markword中记录的信息如图所示:

第9到12个字节叫klasspointer,它是一个指针,指向这个对象对应的类(class);

第13到16个字节存的可能是instancedata,实例数据(成员变量),如果这个对象没有实例数据,存的就是padding,对齐,它的作用是将对象长度补到能被8字节整除。

(如果是数组对象,在classpointer和instancedata之间多了一个arraylength,数组长度)

回过头来看synchronized给对象上锁这个概念,上锁的本质就是在对象的markword里记录锁信息。

那么记录下来的锁信息是什么呢?

指向当前线程的指针。

5.2 synchronized的锁升级过程

最早的时候,synchronized锁的效率很低(因为只使用重量级锁),后来的jdk版本对其进行了优化,现在的synchronized锁包括四种状态:new、偏向锁、轻量级锁(也叫自旋锁)、重量级锁。

越强力的锁,对系统资源的消耗越多,所以能用低一级锁解决的问题,尽量用低一级的锁解决。低一级锁解决不了问题时,锁升级为更高级的锁。

锁的升级过程为:

    新创建对象(new)-->偏向锁-->轻量级锁(自旋锁)-->重量级锁

(这里有个很讨厌的概念:无锁,指的是非重量级锁,理解就可以,不建议使用这个概念)

实际上锁的升级过程要更复杂一些,如图所示:

5.2.1 偏向锁

偏向锁认为,在大部分情况下,synchronized上锁的对象只有一个线程要使用。

给新创建对象上偏向锁:在对象的markword里记录当前线程的指针JavaThread*。

只要有其它线程来抢这个对象(轻度竞争),偏向锁就升级为轻量级锁。

偏向锁机制默认在JVM运行4秒后启动,延迟启动的原因是:在启动JVM时一定会存在多个线程争抢对象的情况。

偏向锁机制启动后,所有的新创建对象都默认上偏向锁——markword中的偏向锁位 置1。当对象的偏向锁位为1而markword里没有记录任何线程的指针时的状态叫作匿名偏向,当有线程来取这个对象的时候,记录线程的指针,匿名偏向锁升级为偏向锁。

5.2.2 轻量级锁(自旋锁)

(轻度竞争下)偏向锁升级为轻量级锁:首先撤销对象的偏向锁状态,然后每个线程在自己的线程栈里生成LR(Lock Record,锁记录),并尝试向对象的markword里写入指向自己LR的指针。哪个线程写入了指针,哪个线程就抢到了对象的轻量级锁。

轻量级锁是通过CAS的方式实现的。

CAS:Compare And Swap,比较和交换。

如图所示,系统使用数据E做完一次运算后,在回写结果时比较当前的E值(图中的N)和运算前的E值是否相等,如果相等则写入运算结果;如果不相等,则取当前的E值重新进行运算,循环此过程。

关于CAS有两个经典问题:

(1) ABA问题:在运算过程中,其它线程对E值进行了多次修改,但最终E值仍与运算前相等,造成“此A非彼A”的问题。如何解决这个问题?

加版本号。

(2) 操作原子性问题:操作“比较E值是否与运算前一致”和操作“回写计算结果”,两个操作的原子性是怎么保证的?

CAS的最底层是一句汇编语言:lock cmpxchg

lock指令:锁总线,CPU在执行指令时,不会被其它CPU打断。

CAS本质上是不断循环的程序,它会占用CPU资源,所以当线程竞争激烈时,轻量级锁升级为重量级锁。

5.2.3 重量级锁

重量级锁将激烈竞争的多个线程放入等待队列,由操作系统来负责线程调度。

放入等待队列的线程不占用CPU资源。

轻量级锁在什么情况下升级为重量级锁?

在jdk1.6以前,如果有线程自旋超过10次,或自旋线程数目超过CPU核数的二分之一时,锁升级。

jdk1.6以后,默认启动自适应自旋,由JVM自己来控制是否升级。

 

volatile

volatile是一个特征修饰符,作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。

比如下面的程序:

XBYTE[2]=55;
XBYTE[2]=56;
XBYTE[2]=57;
XBYTE[2]=58;

对外部硬件而言,上述语句表示对XBYTE[2]进行了四次赋值,但是编译器却会对上述语句进行优化,认为只有XBYTE[2]=58是有效的(即忽略前三条语句,只产生一条机器代码)。如果键入volatile,则编译器会逐一进行编译并产生相应的机器代码(产生四条机器代码)。

volatile有两个作用:保证线程可见性、阻止指令重排序。

6.1 volatile保证线程可见性

一个线程对主存的修改能够及时地被其它线程观察到,这种特性被称之为可见性。

接下来会要讨论两个问题:

  • 为什么要保证线程可见性?
  • volatile是怎么保证线程可见性的?

6.1.1 为什么要保证线程可见性?

首先我们要知道,CPU并不是直接从主存里读数据的,之间要经过缓存。

缓存(即高速缓存,Cache)位于CPU与主存之间,分为L1、L2、L3三级。CPU从主存读数据的时候,会首先到L1里面找需要的数据,L1里面如果没有去L2里面找,L2里面没有去L3里面找,L3里面没有的话,从主存读进来。反过来,数据从内存首先被读入L3,然后被读入L2,然后被读入L1。

缓存从主存读数据的时候(遵循程序局部性原理)会按块读取,每块数据叫一个缓存行(cache line),缓存行的大小一般为64字节。因此主存中的一行数据很可能会被多个CPU的缓存同时读取,在某个CPU对这行数据进行修改后,尽管修改后的数据被写入主存,其它CPU仍然从自己的缓存中读到修改前的数据,这是不应该出现的情况。

同一行数据被读入不同CPU的时候,需要保证各个CPU中数据一致。

6.1.2 volatile是怎么保证线程可见性的?

volatile保证线程可见性的实现方式是:保证缓存行之间的数据一致性。

CPU实现缓存行之间数据一致性的方式是:遵守MESI 缓存一致性协议,如果还是不行就锁总线。

MESI 缓存一致性协议是Intel底层协议,Modified修改、Exclusive独占、Shared共享、Invalid失效。

锁总线通过汇编指令lock。

6.2 volatile禁止指令重排序

CPU乱序执行(指令重排序):

CPU在需要执行两条指令的时候,第一条指令执行比较慢,第二条执行比较快,在两条指令不相关的情况下,有可能先执行第二条指令,再执行第一条指令。

CPU乱序执行在单线程下不会产生问题,但在多线程下可能会产生问题。

volatile禁止CPU进行指令重排序。实现的方式是加内存屏障,内存屏障前后的指令不允许重排序。

JVM要求实现四种内存屏障:

loadload:读指令和读指令之间的屏障,屏障上方的读指令全部完成之后才能执行屏障下方的读指令;

storestore:写指令和写指令之间的屏障,屏障上方的写指令全部完成之后才能执行屏障下方的写指令;

loadstore:读指令和写指令之间的屏障,屏障上方的读指令全部完成之后才能执行屏障下方的写指令;

storeload:写指令和读指令之间的屏障,屏障上方的写指令全部完成之后才能执行屏障下方的读指令。

这四种内存屏障在底层是通过汇编指令实现的。

volatile操作前后的内存屏障:

7 线程间通信

7.1 生产者和消费者问题

生产者和消费者问题:

由多个线程同时操作同一个变量number:

  • 生产者每次操作,number++
  • 消费者每次操作,number--

当number == 0时,消费者不能对其进行操作。

代码实现:

public class Test {
    public static void main(String[] args) {
        Data data = new Data();
        int n = 4; //消费者数目
        int k = 5; //每个消费者的消费额
        new Thread(() -> {
            for (int i = 0; i < n * k; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Producer").start(); //生产者
        for (int id = 0; id < n; id++) {
            new Thread(() -> {
                for (int i = 0; i < k; i++) {
                    try {
                        data.decrement();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }, "Consumer" + id).start(); //消费者们
        }
    }
}

class Data {
    private int number = 0;
    //synchronized方法
    public synchronized void increment() throws InterruptedException {
        while (number > 3) { //while轮询
            this.wait(); //线程等待
        }
        number++;
        System.out.println(Thread.currentThread().getName() + "=>" + number);
        this.notifyAll(); //通知其它线程
    }
    //synchronized方法
    public synchronized void decrement() throws InterruptedException {
        while (number <= 0) { //while轮询
            this.wait(); //线程等待
        }
        number--;
        System.out.println(Thread.currentThread().getName() + "=>" + number);
        this.notifyAll(); //通知其它线程
    }
}

生产者和消费者问题中的三个关键点:

  • synchronized
  • wait和notify
  • while轮询

关于synchronized不再赘述。

7.2 wait和notify

wait():使当前线程等待,可使用notify()或notifyAll()方法唤醒。

wait()和sleep()的区别:

  • wait()是Object类中的方法,而sleep()是Thread类中的方法
  • 调用wait()会释放锁(进入等待状态),而调用sleep()不会释放锁(进入阻塞状态)
  • wait()只能在同步代码块中使用,而sleep()可以在任何地方使用

notifyAll():唤醒所有等待的线程。

notify():唤醒一个等待的线程。

在JDK源码的注释中说道notify()选择唤醒的线程是任意的,但是依赖于具体实现的JVM。

hotspot对notify()的实现是顺序唤醒,即“先进先出”。

7.3 while轮询

wait()方法总是出现在循环中,这是为了防止多线程下的虚假唤醒问题。

在生产者-消费者模型中,可能在某个时刻产品数目为0,多个消费者线程等待。这时生产者生产了一件产品,所有等待的消费者线程被唤醒,但是最终只有一个消费者线程能够获得产品,它被唤醒是有效果的;其它消费者线程只能继续等待,它们被唤醒是无效的,即虚假唤醒。

学习视频链接:

https://www.bilibili.com/video/BV1xK4y1C7aT

加油!(ง •_•)ง

猜你喜欢

转载自blog.csdn.net/qq_42082161/article/details/113861872