《2021春招复习6》基础概念《Java并发》

1、简述下Java的内存模型(Java Memory Model (JMM))

全面理解Java内存模型

​ JVM中存在一个主内存(Main Memory或Java Heap Memory),Java中的所有变量都是存在主存中的,对所有线程共享。每个线程又存在自己的工作内存(本地内存/Working memory),其中主要保存主存中某些变量的copy,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,但线程之间不能直接访问,变量在程序中的传递主要依靠主存中来完成。

引申:线程之间是如何通信的?(联想到操作系统中进程的通信)

​ 线程的通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种共享内存和消息传递。在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

​ java线程之间的通信采用共享内存。

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

2、java内存模型中的可见性、原子性和有序性(线程安全)。

线程安全

首先,什么是线程安全?

1.基本概念

​ 对象的状态:对象的状态是指存储在状态变量(例如实例域和静态域)中的数据。对象的状态可能包括其他依赖对象的域。对象的状态中包含了任何可能影响其外部可见行为的数据。

​ 共享:共享意味着变量可以被多个线程访问。

​ 可变:可变意味着变量的值在其生命周期内可以发生变化。

2.产生线程安全问题的前提条件

​ 多线程环境中存在共享可变的状态变量。一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对该变量的访问。

3、线程安全有两个方法,分别是:
  1. 封装(不共享)。通过封装,来让对象内部状态隐藏,保护起来
  2. 不可变。

线程安全必须要掌握几个特性:

(1)可见性(重点volatile)

​ 是指在线程之间的可见性,一个线程修改的状态对另一个线程是可见的。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方法来实现可见性的。 volatile: 为了保证线程的高效,我们会将变量(假设是A)复制为另一个变量(假设为B),访问A麻烦,所以会代替访问而去访问B,这就会让AB不同步,volatile就是防止这种情况的。

(2)原子性(重点是各种锁)

​ 是指一个操作或者多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。Synchronized块之间的操作就具有原子性。valatile可以保证变量的可见性,但是不能保证复合操作的原子性。(简单来说就是相关操作不会中途被其他线程干扰,一般通过锁同步机制来实现。)

(3)有序性(重点是指令重排)

​ 即程序执行的顺序按照代码的先后顺序执行。Java内存模型中的程序天然有序性可以总结为:**如果在本线程内观察,所有操作都是有序的;如果在一个线程 中观察另一个线程,所有操作都是无序的。**前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和"工作内存主内存同步延迟"现象。(保证线程内串行语义,避免指令重排。)

有序的语义有几层:

  1. 最常见的就是保证多线程运行的串行顺序
  2. 防止重排序引起的问题
  3. 程序运行的先后顺序。比方JMM定义的一些Happens-before规则。

3、happen-before原则是什么?☆

定义了哪些指令不能重排:

八大原则:

​ happen-before 的语义与在什么什么之前发生完全没有关系,其语义是如果 A happen-before B,那么 A 的结果对 B 是可见的。通过这些规则可以保证程序按我们预想的方式运转。

总的来说,HB 原则是对单线程环境下的指令重排序以及多线程环境下的线程间数据的一致性进行的约束。单线程情况下保证串行语义,多线程情况下因为数据的一致性需要我们自己声明和保证,所以 JVM 自行保证了 HB 原则中提出的它认为必须要保证一致性的情况。

1. 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。

首先是单线程的 HB ,前面的操作产生的结果必须对后面的操作可见。而不是前面的操作必须先于后面的操作执行,比如按照 as-if-serial 语义,没有数据依赖的两条指令是可以进行重排序的。而这种情况对于 HB 原则来说,因为两条指令都没有产生对方需要的结果,而不需要对对方可见,及时执行顺序被调转也是符合 HB 原则的。

2. 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。

个人理解强调的是解锁操作在多线程环境的可见性。一个线程进行了解锁操作,对于晚于该操作的加锁操作必须能够及时感应到锁的状态变化。解锁操作的结果对后面的加锁操作一定是可见的,无论两个是否在一个线程。

3. volatile的happen-before原则: 对一个volatile变量的写操作happen-before对此变量的任意操作

对 volatile 变量的写操作的结果对于发生于其后的任何操作的结果都是可见的。x86 架构下volatile 通过内存屏障和缓存一致性协议实现了变量在多核心之间的一致性。

4. happen-before的传递性原则: 如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。

HB 可以说是两项操作之间的偏序关系,满足偏序关系的各项性质,我们都知道偏序关系中有一条很重要的性质:传递性,所以Happens-Before也满足传递性。这个性质非常重要,通过这个性质可以推导出两个没有直接联系的操作之间存在Happens-Before关系

5. 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。

start 放法与其它方法可能并没有数据依赖关系,但是显而易见的,为了程序的正确性,我们必须做到这一点。start 方法造成的函数副作用必须对其它方法可见。

6. 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。

interrupt 方法改变的状态必须对后续执行的检测方法可见。

7. 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。

为了安全的关闭线程,线程中的方法造成的函数副作用必须对线程关闭方法可见。

8. 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。

单线程下对象的创建于销毁存在数据依赖,该条原则强调的是多线程情况下对象初始化的结果必须对发生于其后的对象销毁方法可见。

定义了哪些指令能重排:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行。

4、线程的状态有哪些?☆

Java 线程状态

1、新建状态(NEW):构造了thread实例,但是还没有start
2、可运行(RUNNABLE):线程正在运行或者正等待被cpu执行
3、阻塞(BLOCKED):线程调用synchronized关键字等待获取monitor锁,如果其线程释放了锁就会结束此状态。
4、无限期等待(WAITING):线程调用了无超时的wait、join、park方法,需要其它线程显式唤醒。
5、限期等待(TIMED_WAITING):线程调用了有超时的wait、sleep、join、parkNanos、parkUntil方法,一定时间后会被系统自动唤醒。
6、终止(TERMINATED):异常线程终止/完成了运行

image-20210430104420975

​ 线程创建之后它将处于New(新建)状态,调用start()方法后开始运行,线程这时候也处于Ready(可运行)状态。可运行状态的线程获得了cpu时间片后就处于RUNNING(运行)状态(操作系统隐藏JVM中的READY和RUNNING状态,它只看到RUNNABLE状态)。

​ 当线程执行wait()方法之后,线程进入WAITING(等待)状态。进入等待状态的线程需要依靠其它线程的通知才能够返回到运行状态,而TIME_WAITING(超时等待)状态相当于在等待状态的基础上增加超时限制,比如通过sleep(long millis)方法或wait(long millis)方法可以将Java线程置于TIMED WAITING状态。当超时时间到达后Java线程将会返回RUNNABLE状态,当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到BLOCKED(阻塞)状态。线程在执行Runnale的run方法之后将会进入到TERMINATED(终止)状态。

5、并发级别有哪些?

​ 阻塞、无饥饿、无障碍、无锁、无等待。

由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞、无饥饿、无障碍、无锁、无等待几种。

阻塞

​ 一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字或者重入锁时,我们得到的就是阻塞的线程。synchronize关键字和重入锁都试图在执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。

无饥饿(Starvation-Free)

​ 如果线程之间是有优先级的,那么线程调度的时候总是会倾向于先满足高优先级的线程。也就是说,对于同一个资源的分配,是不公平的!图1.7中显示了非公平锁与公平锁两种情况(五角星表示高优先级线程)。对于非公平锁来说,系统允许高优先级的线程插队,这样有可能导致低优先级线程产生饥饿。但如果锁是公平的,按照先来后到的规则,那么饥饿就不会产生。

无障碍(Obstruction-Free)

​ **无障碍是一种最弱的非阻塞调度。**两个线程如果无障碍地执行,那么不会因为临界区的问题导致一方被挂起。换言之,大家都可以大摇大摆地进入临界区了。那么大家一起修改共享数据,把数据改坏了怎么办呢?对于无障碍的线程来说,一旦检测到这种情况,它就会立即对自己所做的修改进行回滚,确保数据安全。但如果没有数据竞争发生,那么线程就可以顺利完成自己的工作,走出临界区。

​ 如果说阻塞的控制方式是悲观策略,也就是说,系统认为两个线程之间很有可能发生不幸的冲突,因此以保护共享数据为第一优先级,相对来说,非阻塞的调度就是一种乐观的策略它认为多个线程之间很有可能不会发生冲突,或者说这种概率不大。因此大家都应该无障碍地执行,但是一旦检测到冲突,就应该进行回滚。

​ **从这个策略中也可以看到,无障碍的多线程程序并不一定能顺畅运行。因为当临界区中存在严重的冲突时,所有的线程可能都会不断地回滚自己的操作,而没有一个线程可以走出临界区。这种情况会影响系统的正常执行。**所以,我们可能会非常希望在这一堆线程中,至少可以有一个线程能够在有限的时间内完成自己的操作,而退出临界区。至少这样可以保证系统不会在临界区中进行无限的等待。

**这也是利用cas理论的思想:**一种可行的无障碍实现可以依赖一个"一致性标记"来实现。线程在操作之前,先读取并保存这个标记,在操作完成后,再次读取,检查这个标记是否被更改过,如果两者是一致的,则说明资源访问没有冲突。如果不一致,则说明资源可能在操作过程中与其他线程冲突,需要重试操作。而任何对资源有修改操作的线程,在修改数据前,都需要更新这个一致性标记,表示数据不再安全。

数据库中乐观锁,应该比较熟悉,表中需要一个字段version(版本号),每次更新数据version+1,更新的时候将版本号作为条件进行更新,根据更新影响的行数判断更新是否成功,伪代码如下:

1.查询数据,此时版本号为w_v	
2.打开事务	
3.做一些业务操作	
4.update t set version = version+1 where id = 记录id and version = w_v;//此行会返回影响的行数c	
5.if(c>0){	
        //提交事务	
    }else{	
        //回滚事务	
    }

多个线程更新同一条数据的时候,数据库会对当前数据加锁,同一时刻只有一个线程可以执行更新语句。

无锁(Lock-Free)

无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。一个典型的特点是可能会包含一个无穷循环。在这个循环中,线程会不断尝试修改共享变量。如果没有冲突,修改成功,那么程序退出,否则继续尝试修改。但无论如何,无锁的并行总能保证有一个线程是可以胜出的,不至于全军覆没。至于临界区中竞争失败的线程,他们必须不断重试,直到自己获胜。如果运气很不好,总是尝试不成功,则会出现类似饥饿的现象,线程会停止。

下面就是一段无锁的示意代码,如果修改不成功,那么循环永远不会停止。

while(!atomicVar.compareAndSet(localVar, localVar+1)){	
        localVal = atomicVar.get();	
}

无等待

​ 无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步扩展。它要求所有线程都必须在有限步内完成,这样不会引起饥饿问题。如果限制这个步骤的上限,还可以进一步分解为有界无等待和线程数无关的无等待等几种,他们之间的区别只是对循环次数的限制不同。

​ 一种典型的无等待结果就是RCU(Read Copy Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,先获取原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。

6、创建线程的几种方式☆

Java中创建线程主要有三种方式:

一、继承Thread类创建线程类

(1)定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。

(2)创建Thread子类的实例,即创建了线程对象。

(3)调用线程对象的start()方法来启动该线程。


public class FirstThreadTest extends Thread {

    int i = 0;

    //重写run方法,run方法的方法体就是现场执行体
    public void run() {
        for (; i < 100; i++) {
            System.out.println(getName() + "  " + i);
        }
    }

    public static void main(String[] args) {

        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + "  : " + i);
            if (i == 50) {
                new FirstThreadTest().start();
                new FirstThreadTest().start();
            }
        }
    }
}

​ 上述代码中Thread.currentThread()方法返回当前正在执行的线程对象。GetName()方法返回调用该方法的线程的名字。

二、通过Runnable接口创建线程类

(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。

(2)创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。

(3)调用线程对象的start()方法来启动该线程。


public class RunnableThreadTest implements Runnable{
        private int i;
        public void run()
        {
            for(i = 0;i <100;i++)
            {
                System.out.println(Thread.currentThread().getName()+" "+i);
            }
        }
        public static void main(String[] args)
        {
            for(int i = 0;i < 100;i++)
            {
                System.out.println(Thread.currentThread().getName()+" "+i);
                if(i==20)
                {
                    RunnableThreadTest rtt = new RunnableThreadTest();
                    new Thread(rtt,"新线程1").start();
                    new Thread(rtt,"新线程2").start();
                }
            }

        }
}

三、通过Callable和Future创建线程

(1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。

(2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。

(3)使用FutureTask对象作为Thread对象的target创建并启动新线程。

(4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

实例代码:


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

public class CallableThreadTest implements Callable<Integer> {

    public static void main(String[] args) {
        CallableThreadTest ctt = new CallableThreadTest();
        FutureTask<Integer> ft = new FutureTask<>(ctt);
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + " 的循环变量i的值" + i);
            if (i == 20) {
                new Thread(ft, "有返回值的线程").start();
            }
        }
        try {
            System.out.println("子线程的返回值:" + ft.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

    }

    @Override
    public Integer call() throws Exception {
        int i = 0;
        for (; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
        return i;
    }
}

四、使用线程池例如用Executor框架(工厂方法)(看下面线程池相关问题)

五、创建线程方法对比

(1)采用实现Runnable、Callable接口的方式创见多线程时:

线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。但是缺点是:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。

(2)使用继承Thread类的方式创建多线程时优势是:

编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。缺点是:线程类已经继承了Thread类,所以不能再继承其他父类。

(3)Runnble和Callable的区别:

  1. Callable规定重写的方法是call(),Runnnable规定(重写)的方法是run();
  2. Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
  3. call方法可以抛出异常,run方法不可以。
  4. 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务的执行情况,可取消任务的执行,还可获取执行结果。

7、线程的基本操作和线程协作

基本操作:

1、Daemon ([ˈdiːmən])

守护线程(Daemon Thread)

​ 当所有非守护线程结束时,程序也就终止,同时会杀死所有守护进程。

2、sleep()

​ Thread.sleep(millisec)方法会休眠当前执行的线程,millisec单位为毫秒。

3、yield()

​ 对静态方法Thread.yield()的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。

线程协作:

1、join()和yeild()

​ 在线程中调用另一个线程的join(),会将当前线程挂起,而不是忙等待,直到目标线程结束。比如有线程a和线程·b,虽然b线程先启动,但因为在b线程中调用了a线程的join()方法,b线程会继续等待a线程结束才继续执行,因此最后能够保证a线程的输出先于b线程的输出。

2、wait()notify()notifyAll()

​ 使用wait()挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其他线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行notify()或者notifyAll()方法唤醒挂起的线程,造成死锁。

3、await()signal()signalAll()

​ Java.util.concurrent类库中提供了Condition类来实现线程之间的协调,可以在Condition上调用·await()方法使线程等待,其他线程调用signal()或者signalAll方法唤醒等待的线程。相比于wait()这种等待方式,await()可以指定等待的条件,因此更加灵活,使用Lock来获取一个Condition对象。

wait()和sleep()的区别☆

  1. wait()是Object方法,而sleep()是Thread的静态方法。

  2. wait()会释放锁,sleep()不会。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值