多线程基础

多线程基础

多线程相关概念

  • 线程和进程的概念
    从一定意义上讲,进程就是一个应用程序在处理机上的一次执行过程,它是一个动态的概念,而线程是进程中的一部分,进程包含多个线程在运行。
    相对使用多进程来说,多线程的优势:

    • 进程之间不能共享内存,但线程之间共享内存非常容易。
    • 系统创建线程所分配的资源相对创建进程而言,代价非常小。
  • 并发和并行(单核并发,多核并行
    并行:多个cpu实例或者多台机器同时执行一段处理逻辑,是真正的同时。
    并发:通过cpu调度算法,让用户看上去同时执行,实际上从cpu操作层面不是真正的同时。并发往往在场景中有公用的资源,那么针对这个公用的资源往往产生瓶颈,我们会用TPS或者QPS来反应这个系统的处理能力。

  • 线程安全和同步
    线程安全:指在并发的情况之下,该代码经过多线程使用,��程的调度顺序不影响任何结果。线程不安全就意味着线程的调度顺序会影响最终结果,如不加事务的转账代码:

    void transferMoney(User from, User to, float amount){
      to.setMoney(to.getBalance() + amount);
      from.setMoney(from.getBalance() - amount);
    }
    

    同步:Java中的同步指的是通过人为的控制和调度,保证共享资源的多线程访问成为线程安全,来保证结果的准确。如上面的代码简单加入@synchronized关键字。在保证结果准确的同时,提高性能,才是优秀的程序。线程安全的优先级高于性能。

线程的状态

Thread.state

java.lang.Thread.State线程状态。线程可以处于下列状态之一::NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING(有超时的等待)、TERMINATED。

  1. NEW:
    至今尚未启动的线程的状态。
  2. RUNNABLE:
    可运行线程的线程状态。处于可运行状态的某一线程正在 Java 虚拟机中运行,但它可能正在等待操作系统中的其他资源,比如处理器。
  3. BLOCKED :
    受阻塞并且正在等待监视器锁的某一线程的线程状态。处于受阻塞状态的某一线程正在等待监视器锁,以便进入一个同步的块/方法,或者在调用 Object.wait 之后再次进入同步的块/方法。
  4. WAITING :
    某一等待线程的线程状态。某一线程因为调用下列方法之一而处于等待状态:
    • 不带超时值的 Object.wait
    • 不带超时值的 Thread.join
    • LockSupport.park
      处于等待状态的线程正等待另一个线程,以执行特定操作。 例如,已经在某一对象上调用了 Object.wait() 的线程正等待另一个线程,以便在该对象上调用 Object.notify() 或 Object.notifyAll()。已经调用了 Thread.join() 的线程正在等待指定线程终止。
  5. TIMED_WAITING
    具有指定等待时间的某一等待线程的线程状态。某一线程因为调用以下带有指定正等待时间的方法之一而处于定时等待状态:
    • Thread.sleep
    • 带有超时值的 Object.wait
    • 带有超时值的 Thread.join
    • LockSupport.parkNanos
    • LockSupport.parkUntil
  6. TERMINATED
    已终止线程的线程状态。线程已经结束执行。

线程状态

线程状态转换图:

java-life-cycle.e81ded7b

各种状态一目了然,值得一提的是”blocked”这个状态,线程在Running的过程中可能会遇到阻塞(Blocked)情况:

  1. 调用join()和sleep()方法,sleep()时间结束或被打断,join()中断,IO完成都会回到Runnable状态,等待JVM的调度。
  2. 调用wait(),使该线程处于等待池(wait blocked pool),直到notify()/notifyAll(),线程被唤醒被放到锁定池(lock blocked pool ),释放同步锁使线程回到可运行状态(Runnable)
  3. 对Running状态的线程加同步锁(Synchronized)使其进入(lock blocked pool ),同步锁被释放进入可运行状态(Runnable)。
    此外,在runnable状态的线程是处于被调度的线程,此时的调度顺序是不一定的。Thread类中的yield方法可以让一个running状态的线程转入runnable。

线程优先级别

java 中的线程优先级的范围是1~10,默认的优先级是5。10级最高,优先级高的获得cpu的几率更大,但是并不能保证优先级高执行在优先级之前。最终选择执行由cpu决定。

有时间片轮循机制。“高优先级线程”被分配CPU的概率高于“低优先级线程”。根据时间片轮循调度,所以能够并发执行。无论是是级别相同还是不同,线程调用都不会绝对按照优先级执行,每次执行结果都不一样,调度算法无规律可循,所以线程之间不能有先后依赖关系。

无时间片轮循机制时,高级别的线程优先执行,如果低级别的线程正在运行时,有高级别线程可运行状态,则会执行完低级别线程,再去执行高级别线程。如果低级别线程处于等待、睡眠、阻塞状态,或者调用yield()函数让当前运行线程回到可运行状态,以允许具有相同优先级或者高级别的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。结论:yield()从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。

实现多线程方式

Java中实现多线程的方式有3种

继承Thread类

创建一个新的类,该类继承 Thread 类,然后创建一个该类的实例。
继承类必须重写 run() 方法,该方法是新线程的入口点。它也必须调用 start() 方法才能执行。
该方法尽管被列为一种多线程实现方式,但是本质上是实现了 Runnable 接口的一个实例。

  • 继承自 Thread 类,
  • 重写 run 方法,
  • 创建实例调用 start 方法
public class ThreadTest extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "线程方法被调用");
    }
    public static void main(String[] args) {
        new ThreadTest().start();
    }
}

实现Runnable 接口

创建一个实现 Runnable 接口的类。

重写run()方法,重要的是理解的 run() 可以调用其他方法,使用其他类,并声明变量,就像主线程一样。
在创建一个实现 Runnable 接口的类之后,你可以在类中实例化一个线程对象。

Thread 定义了几个构造方法,Thread(Runnable threadOb,String threadName);是我们经常使用的。thread对象是一个实现 Runnable 接口的类的实例,并且 threadName 指定新线程的名字。

新线程创建之后,你调用它的 start() 方法它才会运行

  • 实现 Runnable 接口
  • 实现 run 方法
  • 创建 Thread 时作为参数传入, 调用 start 方法(代理)
public class RunnableTest implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "线程方法被调用");
    }
    public static void main(String[] args) {
        new Thread(new RunnableTest()).start();
    }
}

实现Callable 接口

Runnable接口受限于run方法的类型签名,而Callable只有一个方法call(),可以有返回值,可以抛出受检异常。
future模式:并发模式的一种,可以有两种形式,即无阻塞和阻塞,分别是isDone和get。其中Future对象用来存放该线程的返回值以及状态

  • 实现 Callable 接口
  • 重写 call 方法
  • 创建执行服务,提交执行,获取结果,关闭服务
public class CallableTest implements Callable<Boolean> {
    @Override
    public Boolean call() throws Exception {
        System.out.println(Thread.currentThread().getName() + "线程方法被调用");
        return true;
    }
    public static void main(String[] args) {
        CallableTest callable = new CallableTest();
        
        //线程池执行
        //创建执行服务
        ExecutorService service = Executors.newFixedThreadPool(1);
        //提交执行
        Future<Boolean> result = service.submit(callable);
        //获取结果
        boolean isTrue = result.get();
        //关闭服务
        service.shutdownNow();
        
        //FutureTask执行
        //创建FutureTask
        FutureTask<Integer> task = new FutureTask<>(callable);
        //创建线程对象
        Thread t = new Thread(task);
        t.start();
        //获取线程的运算结果
        Integer result = task.get();
    }
}

Thread 方法

重要方法

序号 方法描述
public void start() 使该线程开始执行;Java 虚拟机调用该线程的 run 方法。
public void run() 如果该线程是使用独立的 Runnable 运行对象构造的,则调用该 Runnable 对象的 run 方法;否则,该方法不执行任何操作并返回。
public final void setName(String name) 设置线程名称,使之与参数 name 相同。
public final void setPriority(int priority) 设置线程的优先级。
public final void setDaemon(boolean on) 将该线程标记为守护线程或用户线程。
public final void join(long millisec) 等待该线程终止的时间最长为 millis 毫秒。
public void interrupt() 中断线程。
public final boolean isAlive() 测试线程是否处于活动状态。

静态方法

序号 方法描述
public static void yield() 暂停当前正在执行的线程对象,并执行其他线程。
public static void sleep(long millisec) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响。
public static boolean holdsLock(Object x) 当且仅当当前线程在指定的对象上保持监视器锁时,才返回 true。
public static Thread currentThread() 返回对当前正在执行的线程对象的引用。
public static void dumpStack() 将当前线程的堆栈跟踪打印至标准错误流。

Java内存模型

并发编程的模型分类

多线程编程需要处理的两个关键问题是:线程之间如何通信 和 线程之间如何同步

  • 通信 是指线程之间以何种机制来交换信息。线程之间的通信机制有两种:共享内存 和 消息传递。

    • 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。

    • 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

  • Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

JMM抽象

Java 线程之间的通信由 Java 内存模型(JMM)控制。JMM 决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程与主内存之间的抽象关系:Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。 。本地内存是 JMM 的一个抽象概念,并不真实存在。

8cedf683cdfacb3cfcd970cd739d5b9d

  • 三大特性(JMM

    synchronized、lock、cas通用

    volatile和内存屏障适用可见性和有序性

    final可见性

    • 原子性

      一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的��

      • 通过 synchronized 关键字保证原子性。
      • 通过 Lock保证原子性。
      • 通过 CAS保证原子性。
    • 可见性

      当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

      • 通过 volatile 关键字保证可见性
      • 通过 内存屏障保证可见性。
      • 通过 synchronized 关键字保证可见性。
      • 通过 Lock保证可见性。
      • 通过 final 关键字保证可见性
      • 通过 CAS保证可见性
    • 有序性

      即程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。

      • 通过 volatile 关键字保证有序性。
      • 通过 内存屏障保证有序性。
      • 通过 synchronized关键字保证有序性。
      • 通过 Lock保证有序性
      • 通过 CAS保证有序性
  • 八种操作

    • Read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用;
    • load(载入):作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中;
    • Use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令;
    • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中;
    • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用;
    • write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中;
    • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态;
    • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • 操作规定

    • 不允许 read 和 load、store 和 write 操作之一单独出现。即使用了 read 必须 load,使用了 store 必须 write
    • 不允许线程丢弃他最近的 assign 操作,即工作变量的数据改变了之后,必须告知主存
    • 不允许一个线程将没有 assign 的数据从工作内存同步回主内存
    • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 assign 和 load 操作
    • 一个变量同一时间只有一个线程能对其进行 lock。多次 lock 后,必须执行相同次数的 unlock 才能解锁
    • 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值
    • 如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量
    • 对一个变量进行 unlock 操作之前,必须把此变量同步回主内存

【备注】
多线程中,java线程的工作内存和栈的区别是什么?

工作内存时对主内存的一个缓存,实质也是堆内存一部分(可以这么理解,就是在堆内存中划分了个区域),这也就体现了volatile关键字的作用了,而栈内存时线程私有的与工作内存不是一个

指令重排

在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。
在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。

JMM与JVM的区别

问题:

  • Java内存模型里讲了工作内存与主内存,每个线程都有自己的工作内存,工作内存中保存的变量是主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,不能直接操作主内存中的变量,这个原因造成了多线程的安全问题,听完后可以理解。
  • 以前还看过了关于线程堆栈的内容,每一个运行在Java虚拟机里的线程都拥有自己的线程栈,一个线程仅能访问自己的线程栈,一个线程创建的本地变量对其它线程不可见;堆是所有线程共享的内存区域,存放对象实例。
  • 堆栈及工作内存、主内存分开讲都理解,但是合在一起就搞不懂它们之间的关系了,不是很明白,栈是否就在线程的工作内存中?堆是否就在主内存中?还是说它们的划分层次压根就不同?

答案:
工作内存是JMM【Java内存模型(JMM)】里的概念,堆栈是JVM【Java内存区域(运行时数据区)】里的概念,他们属于不同层次的概念,两者是��同的概念,没必须放在一起理解。
Java内存区域是指JVM运行时数据分区域存储,而Java内存模型是定义了线程和主内存之间的抽象关系,了解Java内存模型是学好Java并发编程的基础。

可以借助以下两个图理解一下:

163091742808827

163091744759007

Java内存模型和硬件内存体系结构是不同的。硬件内存体系结构不区分线程栈和堆。在硬件上,线程栈和堆都位于主内存中。线程栈和堆的一部分有时可能出现在CPU缓存和内部CPU寄存器中(JMM内存模型可能更指这个)。如图所示:

163091750713711

java的JMM和堆/栈之间的联系是什么?

乐观锁

实现方式

乐观锁一般会使用版本号机制或CAS算法实现。

版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

CAS算法

compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试

由于是直接调用CPU中的cmpxchg指令,所以可以保证原子性。

关于自旋锁,大家可以看一下这篇文章,非常不错:《 面试必备之深入理解自旋锁》

缺点

ABA 问题是乐观锁一个常见的问题

ABA 问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 ABA问题。

JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

循环时间长开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

CAS与synchronized的使用情景

简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)

  1. 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  2. 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞竞争切换后继续竞争锁稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

posted @ 2022-07-12 18:46  Faetbwac  阅读(30)  评论(0编辑  收藏  举报