并发编程

148 阅读15分钟

并发编程

1.什么是进程?

进程是程序执行的基本单位,是程序的一次执行过程。因此进程是动态的,程序的一次执行其实就是进程从创建到结束。

2.什么是线程?

线程和进程类似,是进程中的最小的执行单元。一个进程可以产生多个线程,进程内的多个同类线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器、本地方法栈和虚拟机栈。系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

3.进程和线程的区别?

线程是进程的最小运行单元。多个进程之间互不干扰,但是多个线程之间可能会互相影响。线程的执行开销小,但是不利于资源的管理和保护,而进程则相反。

4.为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?

程序计数器:

1.程序计数器的作用是记录线程内方法的执行位置,在线程进行切换时可以知道我上一次执行到哪里了。

2.可以通过字节码解释器修改程序计数器来依次读取指令,控制代码运行(顺序执行、选择、循环、异常处理)。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈:

虚拟机栈:每一个Java方法调用的时候,都会开辟一个独立的栈帧,用于保存局部变量、操作数栈和常量池引用等。每一个Java方法的调用到执行完毕都表示一个栈帧的入栈出栈的过程。

本地方法栈:和虚拟机栈相似,只是本地方法栈处理的是虚拟机用到的Native方法。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的

堆和方法区:

堆和方法区使用来存储一些线程共享的数据。堆内存是Java内存中最大的一块内存,主要用来存储新创建的对象(几乎所有的对象都在这里分配内存空间),方法区用来存储一些类信息、常量、静态变量、即时编译器编译后得到的代码等数据。

5.并发与并行?

并发:两个以及两个以上的作业在同一时间段执行。

并行:两个以及两个以上的作业在同一刻执行。

判断依据:是否在同时执行两个或以上作业

6.同步于异步?

同步: 调用发出之后,只能等待该调用返回结果才能继续往下执行

异步:调用翻出之后,不用等到该调用返回结果就能继续往下执行。

7.为什么要使用多线程?

1.线程与线程之间切换的消耗远远小于进程的消耗。而且如果是多核CPU情况下,多个线程还能都同时进行。

2.在处理一个耗时长的任务时,如果使用的是单线程,那么久只能等待这个任务执行完毕才能执行其他任务;如果用多线程可以避免资源浪费、长时间等待这个过程

8.使用多线程可能会带来的问题?

1.多线程的目的主要就是用来提供程序执行效率的,但是多线程不总是能提高程序执行效率的,当开启的线程太多时,可能会导致频繁的进行切换导致工号增加。

2.多线程就意味着需要加强对共享资源的管理和维护,可能会发生死锁、内存泄漏、线程不安全等问题。

9.线程安全和线程不安全?

其实就是看多个线程对同一个资源的读取和修改操作时,会不会发生数据错误。

线程安全:多个线程读取同一个数据时,不会出现数据不一致、数据覆盖、数据丢失等问题。

线程不安全:反之。

10.线程的生命周期?

  1. 新建状态:线程创建了但是还未调用start()方法。
  1. 运行状态:线程调用了start()方法之后,线程就进入了运行状态。
  1. 阻塞状态:线程需要等待别的线程的锁释放。
  1. 等待状态:表示该线程需要等待其他线程做出一些特定动作(通知或中断)
  2. 超时等待状态:在指定时间内等待未获得结果时就直接返回了,不在等待了,不会像等待状态一样一直等待。
  3. 终止状态:线程执行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

Java 线程状态变迁图

11.什么是线程上下文切换?

以下情况会导致线程上下文切换:

  1. 调用了wait()方法或者是sleep()方法主动让出CPU。
  2. CPU时间片使用完,被动让出CPU。操作系统防止一个线程长时间占用CPU导致其他线程或进程饿死。
  3. 调用阻塞类型的系统中断,导致线程阻塞。
  4. 线程执行完。

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

12.什么是线程死锁?如何避免死锁?

概念:一个或多个线程在持有其他线程需要的资源的同时又需要请求其它线程的所持有的线程而形成的请求环路。

线程A持有资源a,请求资源b ; 线程B持有资源b,请求资源a。

死锁产生的四个条件:

1.互斥:该资源任意时刻只能被一个资源所占有

2.占有且等待:线程在占有已有资源不释放的同时,请求其他线程的资源

3.不可剥夺条件:线程占有的资源不能被其他线程所剥夺,必须等线程使用完。

4.循环等待:所有线程等待资源形成一个环路。

如何避免死锁:

如何避免死锁其实就是破坏死锁形成的四个原因就行了。

1.一个资源可以被多个线程所同时占有,但是这样会导致线程不安全,不推荐

2.在占有已有资源又长时间请求资源时,可以设置请求资源的超市时长,超时自动释放资源,保证其他线程能运行,推荐。

3.资源可以被剥夺,或导致线程不安全问题,不推荐。

4.设置资源请求顺序,合理分配资源,推荐

13.sleep() 方法和 wait() 方法对比?

1.sleep()方法不释放资源,wait()方法释放资源。

2.sleep()方法需指定徐勉时间,休眠时间过后会自动启动线程;wait()方法不会自动启动线程,需要别的线程调用notify()方法或者notifyAll()方法唤醒,或者调用wait(long timeOut)方法设置超时时间,指定时间过后自动恢复。

3.sleep()线程被用来暂停执行,wait()方法一般用于线程之间通信。

4.sleep()方法属于Thread类的,而wait()方法是Object类的。

14.wait()方法为什么不定义在Thread类中?

wait()方法是让线程等待,并且会释放对象锁。每一个对象资源都可以加锁、释放锁,所以wait()方法自然是操作对象来进行的。

类似的问题:sleep()方法为什么定义在Thread中?

sleep()是让该线程休眠,不涉及对象类,也无需获取对象锁。

15.可以直接调用Thread类的run()方法吗?

可以,当直接调用run()方法的时候,run()会被当做普通方法,被main线程正常调用。而要开启线程调用Thread的start()方法,然后内部回去执行run()方法。

16.volatile关键字的作用?

被volatile关键字标记表示告诉编译器这个变量是共享且不稳定的,需要线程去主存中获取变量。保证了数据的可见性,但是不保证数据的原子性。

17.如何防止指令重排?

1.volatile除了可以使变量可见之外,还可以防止指令重排。被volatile关键字修饰的变量,在读写的时候,会加上内存屏障来防止指令重排。

2.在Java类中,Unsafe类提供了三个开箱即用的内存屏障相关的方法,也可以达到volatile关键字的效果,但是实现会比较麻烦。

public native void loadFence();
public native void storeFence();
public native void fullFence();

19.手写一个双检索的单例模式?

代码:

public class Singleton{
    private volatile static Singleton uniqueSingleton ;
    
    private Singleton(){
        
    }
    
    public static Singleton getSingleton(){
        if(null == uniqueSingleton){
            synchronized(Singleton.class){
                if(null == uniqueSingleton){
                    uniqueSingleton = new Singleton();
                }
            }
        }
        return uniqueSingleton;
    }
    
}

uniqueSingleton为什么用volatile关键字修饰。

uniqueSingleton = new Singleton();这行代码可以分为三个过程 1.分配内存空间

2.初始化uniqueSingleton

3.将uniqueSingleton指向所分配的内存空间地址 这三个过程可能会出现指令重排,就是由原本的1->2->3变为1->3->2。在第一个线程出现指令重排的时候,第二个线程进来判断,此时null == uniqueSingleton不成立,但是此时uniqueSingleton可能还未初始化。

乐观锁和悲观锁

20.什么是悲观锁

悲观锁总是做最坏打算,默认每个线程都会去修改共享数据而造成线程不安全的问题,所以每次操作共享数据都会去上锁。每次只能有一个线程操作共享数据,其他线程阻塞,知道线程使用完共享资源。 像 Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

弊端:当并发比较高的时候,每次只有一个线程能执行,会造成大量线程阻塞,加上增大了上下文切换的效率,导致性能开销增大。而且还有可能导致死锁的发生,导致系统奔溃。

21.什么是乐观锁

乐观锁则总是做最好打算,操作共享资源时不会上锁,只是在修改提交的时候会去验证资源是否被其他线程所修改。(比如判断数据的版本号操作以及CAS操作)

在高并发的情况下,乐观锁效率会比悲观锁高,而且不会发生死锁的问题。但是当频繁发生资源冲突时(频发写操作 ),会频繁失败和重试,导致CPU飙升。

22.如何选择乐观锁和悲观锁

当写操作比较频繁的时候建议使用悲观锁,乐观锁会频繁发生资源冲突,可能导致CPU飙升;但是若能够解决频繁失败和重试的问题,比如LongAdder,就可以使用悲观锁,这样效率会有提升。

当写操作不是特别多的时候,使用乐观锁,这能大大提高效率。

23.如何实现乐观锁

1.版本号机制

2.CAS(使用的多)

24.版本号机制

一般有有一个版本号和数据绑定,每修改一次都会变更版本号。在修改数据时需要判断操作前后的版本号是否一致,不一致则不允许修改,修改数据和版本号的操作必须是原子性的。(这个版本号可以使用数据库来保存或者是Redis)

25.CAS

CAS 全称是Compare And Swarp(比较并交换)。

CAS使用广泛,被运用在各大框架中。主要是有一个预期值和要修改的值进行比较,如果两个值一致,才会更新要修改的值为新值。

CAS操作是原子性的操作。

主要包括三个操作数:

V:要修改的变量(var)

E:预期值(Expected)

N:新值(New)

当且仅当V==E的时候,N才能赋值给V;否则认为改数据已被其他线程修改,本线程放弃修改。

eg:(不包括ABA问题)

前提:变量 i = 10 ,现有两个线程同时想操作变量i

1.线程1想要将i修改为20 , 这个时候 V = 10 , E = 10 ,N = 20 ,此时满足V == E ,可以执行V = N。

2.线程2想要将i修改为5,因为两个线程同时进行,所以这个时候E = 5 , 但此时线程1先一步执行完毕,V = 20,此时V != E,则会放弃本次修改。

ava 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

sun.misc包下的Unsafe类提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作。

26.CAS的存在的问题。

1.存在ABA问题:

在判断V == E的过程中,V == E 成立并不代表着V 就没有被修改过,也可能是经过多次修改,最终又修改为了原值。A->B->A ,虽然值没有变化,但是已经是被修改过的。这个问题可以加上版本号或者是时间戳的比较,只有两个都相等才允许修改。

2.循环开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

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

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

synchronized 关键字

27.synchronized关键字有什么作用?

synchronized关键字可用来修饰方法,修饰代码块。被synchronized关键字包裹的代码每次只能有一个线程访问。

28.synchronized的使用方式

1.修饰普通方法

表示该方法是同步方法,锁的对象是当前调用方法的对象实例

2.修饰静态方法

加锁对象是该类对象,因为静态成员不属于任何实例,是隶属于该类的。

3.修饰代码块

表示这个代码块内的代码是同步的,加锁对象是synchronized(){}括号内的对象。所以里面既可以是当前实例对象this也可以是类对象this.getClass()

注意:最好不要使用synchronized(String a ){},因为String会放在缓存池。

29.构造方法可以使用synchronized修饰吗?

不可以,构造方法本来就是同步的,不需要用synchronized修饰。

30.synchronized的原理

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

31.synchronized 和 volatile 有什么区别?

1.synchronized 和 volatile不互斥,可以同时存在。

2.volatile相比synchronized来说性能更高,但是前者只能修饰变量,且只能保证变量的可见性,不能保证原子性;后者则用处较为广泛。

ReentrantLock

32.什么是ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

ReentrantLock有一个内部类Sync,添加锁和释放锁的大部分操作都是在该类中实现的,Sync有公平锁(FairSync)和非公平锁(NonFairSync)两个子类。

// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
​

33.公平锁和非公平锁有什么区别?

公平锁:在上一个线程释放资源后,先申请的线程优先获取锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

非公平锁:在上一个线程释放锁后,先申请的线程不一定先获取,可以是随机或者是设置优先级。性能更好,但可能会导致某些线程永远无法获取到锁。