Java自我修养-高效并发编程

581 阅读16分钟

声明:本篇内容是对博主「我是祖国的花朵」的《Java开发岗高频面试题全解析》的个人总结和补充!

进程和线程的区别

  • 进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位;
  • 线程是进程的一个实体,一个进程中一般拥有多个线程。线程之间共享地址空间和其它资源(所以通信和同步等操作,线程比进程更加容易)
  • 线程一般不拥有系统资源,但是也有一些不可少的资源(使用ThreadLocal存储)
  • 线程上下文的切换比进程上下文切换要快很多

线程上下文切换比进程上下文切换快的原因,可以总结如下:

  • 进程切换时,涉及到当前进程的CPU环境的保存和新被调度运行进程的CPU环境的设置
  • 线程切换时,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作

进程之间常见的通信方式

  • 通过使用套接字Socket来实现不同机器间的进程通信 (不同机器)
  • 通过映射一段可以被多个进程访问的共享内存来进行通信
  • 通过写进程和读进程利用管道进行通信(管道本质上就是一个文件,前面的进程以写方式打开文件,后面的进程以读方式打开。这样前面写完后面读,于是就实现了通信)link:Linux 的进程间通信:管道

多线程与单线程的关系

  • 多线程是指在一个进程中,并发执行了多个线程,每个线程都实现了不同的功能;
  • 在单核CPU中,将CPU分为很小的时间片,在每一时刻只能有一个线程在执行,是一种微观上轮流占用CPU的机制。由于CPU轮询的速度非常快,所以看起来像是“同时”在执行一样;
  • 多线程会存在线程上下文切换,会导致程序执行速度变慢;
  • 多线程不会提高程序的执行速度,反而会降低速度。但是对于用户来说,可以减少用户的等待响应时间,提高了资源的利用效率。 解析:

搞清楚多线程和单线程之间的区别,有助于我们理解为什么要使用多线程并发编程。多线程并发利用了CPU轮询时间片的特点,在一个线程进入阻塞状态时,可以快速切换到其余线程执行其余操作,这有利于提高资源的利用率,最大限度的利用系统提供的处理能力,有效减少了用户的等待响应时间。

🤔:多线程是指在一个进程中,并发执行了多个线程,每个线程都实现了不同的功能!

扩展:都写的太精彩啦orz

  1. 我是一个线程(上) - 码农翻身
  2. 磁盘I/O - 是一只蚂蚁
    • I/O的本质,就是把外界的数据输入给CPU,或者让CPU向外界输出数据。
    • CPU与外界交互,其实都是通过访问外部设备的存储单元;而访问存储单元,都是通过“寻址”。

线程的状态有哪些?

线程的状态包括:新建状态、运行状态、阻塞状态和消亡状态。其中阻塞等待状态又分为BLOCKED, WAITING和TIMED_WAITING状态。

  1. NEW:一个已经创建的线程,但是还没有调用start方法启动的线程所处的状态;
  2. RUNNABLE:当我们创建线程并启动之后,就属于Runnable状态。该状态包含两种可能。有可能正在运行,或者正在等待CPU资源;
  3. BLOCKED:当线程准备进入syncchronized同步代码块/方法时,需要申请一个监视器锁而进行的等待,会使线程进入Blocked状态;
  4. WAITING:该状态的出现是因为调用了Object.wait()或者Thread.join()或者LockSupport.park()。处于该状态下的线程在等待另一个线程执行一些其余action来将其唤醒;
  5. TIMED_WAITING:该状态和上一个状态其实是一样的,是不过其等待的时间是明确的。read.sleep()方法、Object.wait(long)或者Thread.join(long)或者LockSupport.parkNanos(long)或者LockSupport.parkUntil(long);
  6. TERMINATED:消亡状态,线程执行结束了。

扩展:Java线程的5种状态及切换(透彻讲解)-京东面试

多线程编程中常用的函数比较:

sleep和wait的区别

  • sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)
  • wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁

join 方法:t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程。

yield 方法:该方法使得线程放弃当前分得的 CPU 时间。但是不使线程阻塞,即线程仍处于可执行状态,随时可能再次分得 CPU 时间。

经典多线程问题:多线程循环打印问题

扩展:膜拜大佬门orz

  1. 【Java】Thread类中的join()方法原理
  2. Java多线程之wait(),notify(),notifyAll()
  3. 聊聊并发:(三)wait、sleep、notify、notifyAll分析

线程的死锁

死锁的起因是多个线程之间相互等待对方而被永远暂停(处于非Runnable)。死锁的产生必须满足如下四个必要条件:

  • 资源互斥:一个资源每次只能被一个线程使用
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程已经获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

如何避免死锁的发生?

  • 锁排序法:通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。
  • 使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁。

死锁vs锁死

线程锁死是指等待线程由于唤醒其所需的条件永远无法成立,或者其他线程无法唤醒这个线程而一直处于非运行状态(线程并未终止)导致其任务 一直无法进展。

线程锁死分为如下两种:

  1. 信号丢失锁死:信号丢失锁死是因为没有对应的通知线程来将等待线程唤醒,导致等待线程一直处于等待状态。 典型例子是等待线程在执行Object.wait( )/Condition.await( )前没有对保护条件进行判断,而此时保护条件实际上可能已经成立,此后可能并无其他线程更新相应保护条件涉及的共享变量使其成立并通知等待线程,这就使得等待线程一直处于等待状态,从而使其任务一直无法进展。

  2. 嵌套监视器锁死:嵌套监视器锁死是由于嵌套锁导致等待线程永远无法被唤醒的一种故障。 比如一个线程,只释放了内层锁Y.wait(),但是没有释放外层锁X; 但是通知线程必须先获得外层锁X,才可以通过 Y.notifyAll()来唤醒等待线程,这就导致出现了嵌套等待现象。

原子性、可见性和有序性

答:多线程环境下的线程安全主要体现在原子性、可见性和有序性方面。

原子性

定义:对于涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。即,其它线程不会“看到”该操作执行了部分的中间结果。

举例:银行转账流程中,A账户减少了100元,那么B账户就会多100元,这两个动作是一个原子操作。我们不会看到A减少了100元,但是B余额保持不变的中间结果。

原子性的实现方式:

  • 利用锁的排他性,保证同一时刻只有一个线程在操作一个共享变量
  • 利用**CAS(Compare And Swap)**保证
  • Java语言规范中,保证了除long和double型以外的任何变量的写操作都是原子操作
  • Java语言规范中又规定,volatile关键字修饰的变量可以保证其写操作的原子性(不能保证读写操作的原子性

原子性针对多个线程的共享变量,对于局部变量不存在共享问题,也就无所谓是否是原子操作。

可见性

定义:可见性是指一个线程对于共享变量的更新,对于后续访问该变量的线程是否可见的问题。

为了阐述可见性问题,我们先来简单介绍处理器缓存的概念。
   现代处理器处理速度远大于主内存的处理速度,所以在主内存和处理器之间加入了寄存器,高速缓存,写缓冲器以及无
效化队列等部件来加速内存的读写操作。也就是说,我们的处理器可以和这些部件进行读写操作的交互,这些部件可以
称为处理器缓存。

   处理器对内存的读写操作,其实仅仅是与处理器缓存进行了交互。一个处理器的缓存上的内容无法被另外一个处理器读
取,所以另外一个处理器必须通过缓存一致性协议来读取的其他处理器缓存中的数据,并且同步到自己的处理器缓存
中,这样保证了其余处理器对该变量的更新对于另外处理器是可见的。

在单处理器中,为什么也会出现可见性的问题呢?

   单处理器中,由于是多线程并发编程,所以会存在线程的上下文切换,线程会将对变量的更新当作上下文存储起来,导
致其余线程无法看到该变量的更新。所以单处理器下的多线程并发编程也会出现可见性问题的。

可见性如何保证?

   当前处理器需要刷新处理器缓存,使得其余处理器对变量所做的更新可以同步到当前的处理器缓存中;
   当前处理器对共享变量更新之后,需要冲刷处理器缓存,使得该更新可以被写入处理器缓存中。

大致意思是:处理器和内存之间有缓存,我们对内存的读写操作其实是与处理器缓存进行的交互,导致线程对变量的更新无法被另一个线程可见(多处理器&单处理器)

有序性

定义:有序性是指一个处理器上运行的线程所执行的内存访问操作在另外一个处理器上运行的线程来看是否有序的问题。

重排序:

为了提高程序执行的性能,Java编译器在其认为不影响程序正确性的前提下,可能会对源代码顺序进行一定的调整,导致程序运行顺序与源代码顺序不一致。

重排序是对内存读写操作的一种优化,在单线程环境下不会导致程序的正确性问题,但是多线程环境下可能会影响程序的正确性。

重排序举例: Instance instance = new Instance()都发生了啥? 具体步骤如下所示三步:

  • 在堆内存上分配对象的内存空间
  • 在堆内存上初始化对象
  • 设置instance指向刚分配的内存地址

第二步和第三步可能会发生重排序,导致引用型变量指向了一个不为null但是也不完整的对象。(在多线程下的单例模式中,我们必须通过volatile来禁止指令重排序

多线程安全的三大特性总结

  • 原子性是一组操作要么完全发生,要么没有发生,其余线程不会看到中间过程的存在。注意,原子操作+原子操作不一定还是原子操作
  • 可见性是指一个线程对共享变量的更新对于另外一个线程是否可见的问题。
  • 有序性是指一个线程对共享变量的更新在其余线程看起来是按照什么顺序执行的问题。 可以这么认为,原子性 + 可见性 -> 有序性

扩展:写的针不错,压箱底的收藏orz(可以先看完下面再看这里)

谈谈你对synchronized关键字的理解

synchronized是Java中的一个关键字,是一个内部锁。它可以使用在方法上和方法块上,表示同步方法和同步代码块。在多线程环境下,同步方法或者同步代码块在同一时刻只允许有一个线程在执行,其余线程都在等待获取锁,也就是实现了整体并发中的局部串行。

内部锁底层实现

  • 进入时,执行monitorenter,将计数器+1,释放锁monitorexit时,计数器-1
  • 当一个线程判断到计数器为0时,则当前锁空闲,可以占用;反之,当前线程进入等待状态

synchronized内部锁对原子性的保证:

锁通过互斥来保障原子性,互斥是指一个锁一次只能被一个线程所持有,所以,临界区代码只能被一个线程执行,即保障了原子性。

synchronized内部锁对可见性的保证:

synchronized内部锁通过写线程冲刷处理器缓存和读线程刷新处理器缓存保证可见性。

  • 获得锁之后,需要刷新处理器缓存,使得前面写线程所做的更新可以同步到本线程。
  • 释放锁需要冲刷处理器缓存,使得当前线程对共享数据的改变可以被推送到下一个线程处理器的高速缓冲中。

synchronized内部锁对有序性的保证:

由于原子性和可见性的保证,使得写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即保证了有序性。

调度方式

JVM对资源的调度分为公平调度和非公平调度方式:

  • 公平调度方式:按照申请的先后顺序授予资源的独占权;
  • 在该策略中,资源的持有线程释放该资源的时候,等待队列中一个线程会被唤醒,而该线程从被唤醒到其继续执行可能需要一段时间。在该段时间内,新来的线程(活跃线程)可以先被授予该资源的独占权。

JVM对synchronized内部锁的调度:

JVM对内部锁的调度是一种非公平的调度方式,JVM会给每个内部锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。当锁被持有的线程释放的时候,该锁的入口集中的任意一个线程将会被唤醒,从而得到再次申请锁的机会。被唤醒的线程等待占用处理器运行时可能还有其他新的活跃线程与该线程抢占这个被释放的锁.

流程:获取锁失败进入锁池队列 -> 锁被持有的线程释放,幸运的一个线程被唤醒 -> 被唤醒的线程获得处理器 -> 获得锁 -> 执行同步代码。

谈谈你对volatile关键字的理解

volatile关键字是一个轻量级的锁,可以保证可见性和有序性,但是不保证原子性

  • volatile 可以保证主内存和工作内存直接产生交互,进行读写操作,保证可见性
  • volatile 仅能保证变量写操作的原子性,不能保证读写操作的原子性。
  • volatile可以禁止指令重排序(通过插入内存屏障),典型案例是在单例模式中使用。

volatile在什么情况下可以替代锁?(注意这里说的是什么情况下可以代替锁? -- 做状态变量时)

volatile是一个轻量级的锁,适合多个线程共享一个状态变量锁适合多个线程共享一组状态变量。可以将多个线程共享的一组状态变量合并成一个对象,用一个volatile变量来引用该对象,从而替代锁。

ReentrantLock和synchronized的区别

ReentrantLock是显示锁,其提供了一些内部锁不具备的特性,但并不是内部锁的替代品。显式锁支持公平和非公平的调度方式,默认采用非公平调度

synchronized 内部锁简单,但是不灵活。显示锁支持在一个方法内申请锁,并且在另一个方法里释放锁。显示锁定义了一个tryLock()方法,尝试去获取锁,成功返回true,失败并不会导致其执行的线程被暂停而是直接返回false,即可以避免死锁。

多线程知识

  • CountDownLatch和CyclicBarrier有了解吗?
  • ThreadLocal有了解吗? ->
  • Atmoic有了解吗?
  • 什么是happened-before原则?
  • JVM虚拟机对内部锁有哪些优化?
  • 如何进行无锁化编程?
  • CAS以及如何解决ABA问题? -> JAVA CAS原理深度分析 (CompareAndSwap:区别于sync同步锁的一种乐观锁 -> AtomicInteger:volatile保证可见性,JNI操作CPU的CAS指令(多处理器时给cmpxchg指令加上lock前缀,确保对内存的读-改-写操作原子执行) -> 优:高效解决原子操作 缺:ABA问题,循环时间长开销大,只能保证一个共享变量的原子操作)
  • AQS(AbstractQueuedSynchronizer)的原理与实现? -> Java并发(5)- ReentrantLock与AQS

拓展: