Android 面试系列(二)Java并发

文章目录

多线程、并发

对于 Android 程序员来说,掌握基本的 Android 知识还是不够的。Java 并发这部分,虽然在我们写代码的时候用到的可能并不多,但是面试的时候如果面试官问起来可是一点不含糊,废话不多说,我们一起复习一下吧~
胖虎

多线程的有序性、可见性和原子性是什么意思?

  • 原子性:执行一个或多个操作的时候,要么全部执行,要么都不执行,并且中间过程不会被打断。Java 中的原子性可以通过独占锁和 CAS 去保证。
  • 可见性:指多线程访问同一个变量的时候,一个线程修改了变量的值,其他线程能立刻看到修改的值。锁和 volatile 都能保证可见性。
  • 有序性:程序执行的顺序按照代码先后的顺序执行。锁和 volatile 能够保证有序性。

Java 中的几种锁

  • 相对于 JDK6 之后的 synchronized 关键字,有如下几种锁的状态
    • 偏向锁:没有竞争时,也就是一段可能被多线程访问的代码,经常被同一个线程所访问,JVM 会利用 CAS 操作,在对象头上的 MarkWord 部分设置线程 id,表示这个对象偏向与当前线程。当下一次进入线程,如果 thread id 相等,不需要竞争,直接拿到锁对象。
    • 轻量锁:当有别的线程试图锁定被偏向过的对象,JVM 就会撤销偏向锁,切换到轻量锁实现。依赖 CAS 操作 MarkWord 试图获取锁,重试成功,就使用轻量锁
    • 重量锁:轻量锁通过自旋试图获取锁,获取失败时会先进行自旋锁升级,失败则会升级为重量锁
  • 自旋锁:让 CPU 做无用功,如:执行几条空的汇编指令。目的是为了占着 CPU 不放,等待获取锁的机会。如果自旋时间过程,会影响整体性能。
  • 重入锁:指的是在一个线程中可以多次获取同一把锁。一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,无需再重新获得锁。一定程度上可以避免死锁。synchronized 和 ReentrantLock 都是可重入锁
public class Test{
    public synchronized void doSth1(){
        doSth2();
    }
    
    public synchronized void doSth2(){}
}
  • 悲观锁:一种概念,总是假定最坏的情况,即每次拿数据时,都认为别人会修改。所以每次拿数据时都会上锁,synchronized 和 ReentrantLock 等独占锁就是悲观锁实现。
  • 乐观锁:一种概念,总是假定最好的情况,即每次拿数据时,都认为别人不会修改,所以不会上锁,但是更新时会判断此期间有没有人更新这个数据,可以使用版本号机制和 CAS 算法。适用于多读写少的场景。atomic 包下的类都是使用了乐观锁的一种实现方式,CAS 实现的。
  • 分段锁:一种锁的设计,在 ConcurrentHashMap 中,就使用了分段锁 Segment 的形式实现高效的并发操作。可以细化锁的粒度,仅对要操作的数据区进行加锁。
  • 公平/非公平锁:公平锁指多个线程按照申请锁的顺序获取锁,非公平锁指的是多个线程获取锁的顺序不是按照锁的申请顺序。
  • 独享锁/共享锁:独享锁一次只能被一个线程独享;共享锁指该锁可以被多个线程持有。ReentrantLock 是独享锁。ReadWriteLock 读锁是共享锁,写锁是独享锁。
ReentrantLock
  • ReentrantLock 实现了 Lock 接口,底层实现是 volatile + CAS(乐观锁)
  • Lock 锁不会自动释放,在 finally 中必须释放锁,不然容易造成线程死锁
  • 可以判断锁的状态
  • 锁类型是可重入、可判断、可公平的
  • 竞争资源激烈时比 synchronized 性能好

线程的状态

Thread 类中定义了一个枚举类:

  • NEW:新建的线程,还未调用 start 方法
  • RUNNABLE:调用了 start,在 JVM 中运行的线程,有可能等待某些资源,比如 CPU
  • BLOCKED:线程阻塞,等待锁,获取锁之后重新进入 synchronized 代码块,或重新进入一个之前调用了 Object#wait() 方法的 synchronized 代码块
  • WAITING:调用了 Object.wait(0)、Thread.join 导致进入等待状态
  • TIMED_WAITING:调用了 Object.wait(timeout)、Thread.join(millis)、Thread.sleep 等方法之后进入此状态
  • TERMINATED:线程结束调用进入此状态,完成任务退出或者是异常终止
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1nD2fDWi-1628947812004)(https://github.com/guoxiaoxing/android-open-source-project-analysis/raw/master/art/native/process/java_thread_state.png)]
synchronized
synchronized 原理

synchronized 代码块是由一对 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现,synchronized 同步方法使用了 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

在 Java6 之前,Monitor 完全依赖操作系统内部的互斥锁,需要从用户态切换到内核态,是无差别的重量锁。

Java7 之后对 synchronized 机制进行了改动,提供了三种 Monitor 的实现,也就是三种不同的锁,偏向锁、轻量锁和重量锁。

锁的升级和降级说的就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状态时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

synchronized 本质
  • 保证方法内部或者代码块内部的资源(数据)的互斥访问。即同一时间,由同一个 Monitor 监视的代码,最多只能有一个线程咋访问。
  • 保证线程之间对监视资源的数据同步。即,任何线程在获取到 Monitor 后的第一时间,会先将共享内存中的数据复制到自己的缓存中;任何线程在释放 Monitor 的第一时间,也会先将缓存中的数据复制到共享内存。

synchronized 会在获取锁的线程执行完同步代码时释放锁,线程执行异常时,jvm 也会让线程释放锁

synchronized 修饰 static 方法、普通方法、类、方法块区别
  • synchronized 修饰实例方法时,monitor 是本对象实例
  • synchronized 修饰静态方法时,获取的是类锁(类的字节码文件对象),monitor 是对象的 Class 实例。由于静态成员不专属于任何一个实例对象,是类成员,因此通过 class 对象锁可以控制静态成员的并发操作。
  • synchronized 修饰类,就是将类对象作为 monitor,比如 XX.class,效果等同修饰 static 方法
  • synchronized 修饰代码块时,获取的是对象锁,monitor 是括号中传入进来的对象实例
wait、sleep 的区别
  • sleep 是 Thread 的静态方法,可以在任何地方调用
  • wait 是 Object 成员方法,只能在 synchronized 代码块中调用,否则会抛出 IllegalMonitorStateException 异常
  • sleep 不会释放共享资源所,wait 会释放共享资源锁

最大的区别是在等待时,wait() 方法会释放锁,sleep() 方法不会释放锁,还是一直持有锁。wait() 通常被用于线程间交互,sleep 常用于暂停执行。

notify 运行过程
  • 当线程 A(消费者)调用 wait() 方法后,线程 A 让出锁,暂停当前线程,自己进入等待状态,同时加入锁对象的等待队列。
  • 线程 B(生产者)获取锁后,调用 notify()/notifyAll() 方法,通知所对象的等待队列,使线程 A 从等待队列进入阻塞队列。
  • 线程 A 进入阻塞队列后,直到线程 B 释放锁后,线程 A 重新竞争锁,得到锁后,从 wait() 方法后继续执行。
对象锁和类锁
  • 对象锁:Java 的所有对象都含有一个互斥锁,由 JVM 自动获取和释放。线程进入 synchronized 方法时候获取该对象的锁,如果锁被别的线程占用,则等待;synchronized 正常返回或者异常终止, JVM 会自动释放锁。
  • 类锁:对象锁是用来控制实例方法的同步问题,类锁是用来控制静态方法/静态变量互斥体之间的同步。类锁与对象锁不同,一个是类的 Class 对象的锁,一个是类的实例的锁。一个线程访问静态 synchronized 时候,允许另一个线程访问对象的实例 synchronized 方法。反过来也成立,因为这是两种锁。
volatile
volatile 的作用和原理
  • 只能用来修饰变量,适合修饰可能被多线程同时访问的变量
  • 相当于轻量级的 synchronized,volatile 能保证有序性(禁止指令重排序)、可见性
    • 有序性:
    • 可见性:被 volatile 修饰的变量改变后会立即同步到主内存,一个线程释放了锁之后,另一个线程在使用时也会从主内存重新拷贝一份,保持变量可见性
双重检查单例,为什么要加 volatile?

volatile 想要解决的问题是,在另一个线程中想要使用 instance,发现 instance!= null,但实际上此时 instance 还未初始化完毕的问题。instance = new Instance() 其实可以拆分成三条指令。1)分配内存 2)初始化对象 3)将 instance 对象指向分配的内存控件。volatile 可以禁止指令重排序,保证先执行 2),再执行 3)

一个 int 变量用 volatile 修饰,多线程去操作 i++,是否线程安全?

不安全。volatile 只能保证可见性和有序性,不能保证原子性。i++ 实际上会被分为多步完成:

  1. 获取 i 的值
  2. 执行 i+1
  3. 将结果赋值给 i

volatile 只能保证这 3 步不被重新排序,多线程情况下,可能两个线程同时获取 i,执行 i+1,然后都赋值结果 2,实际上应该执行两次 +1 操作。

如何保证 i++ 线程安全?AtomicInteger 的底层实现原理?

可以使用 java.util.concurrent.atomic 包下的原子类,如 AtomicInteger。
实现原理是用 CAS 自旋操作更新值,CAS 即 compare and swap 的缩写。CAS 有三个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当旧的预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。自旋就是不断尝试 CAS 操作指导成功为止。

CAS

CAS,Compare and Swap,即比较交换,设计并发算法时常用到的技术,java.util.concurrent 包完全建立在 CAS 之上。属于硬件级别的操作,因此效率比锁更高一些

CAS 实现原子操作会出现什么问题
  • ABA问题。因为 CAS 在操作的时候,会检查值有没有变化,如果没有变化则更新。但如果一个值之前是 A,变成了 C,又变回了 A,那么使用 CAS 进行检查时,会发现它的值没有发生变化,但实际上发生了变化。ABA 问题可以通过添加版本号解决。Java 1.5 开始,JDK 的 Atomic 包里提供了一个 AtomicStampedReference 来解决 ABA 问题
  • 循环时间长,开销大
  • 只能保证一个共享变量的原子操作,可以合并成一个对象进行 CAS 操作
synchronized 和 volatile 关键字的作用和区别?
  • volatile 本质是告诉 jvm,当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其它线程被阻塞住。
  • volatile 仅能用于修饰变量;synchronized 可以修饰变量、方法和类
  • volatile 仅能保证变量修改的可见性、有序性,不能保证原子性;synchronized 可以保证可见性、原子性、有序性
  • volatile 不会造成线程阻塞;synchronized 可能会造成线程阻塞
  • volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化
有了 synchronized,为什么还要 volatile?
  • synchronized 是重量级的操作,对系统性能有较大影响。而 volatile 比较轻量级,可以保证变量的单次读/写操作的原子性和可见性,如 long 和 double 类型变量
  • volatile 不会造成线程的阻塞,synchronized 可能会造成线程的阻塞
synchronized 的错误用法

当我们想保证对一个变量访问的线程安全,那么最好的做法是将这个变量本身声明为线程安全的,如 AtomicXX 等,然后为这个变量声明 synchronized 标记的 get()、set() 方法。即,我们如果要保证一块内存的线程安全,那么就需要保证这个类中所有对这块内存访问的地方都是 synchronized 修饰(需要为同一个对象锁、类锁)来保证其读写访问方向对等的锁住。

synchronized不能保证原子性吗?

ThreadLocal

ThreadLocal 可以理解为线程变量,可以为每个线程创建专属于自己的局部变量。每个 Thread 中包含一个 ThreadLocalMap 对象 threadLocals,存储此线程独享的数据,ThreadLocalMap 的 key 是 ThreadLocal 对象,value 是线程独享的数据。

  • 通过 createMap 方法创建 ThreadLocalMap 对象赋值给当前线程
  • 需要先调用 set,再调用 get,或者重写 initialValue 方法
  • 可以实现单个线程单例,以及单个线程上下文信息的存储。
  • 实现线程安全,每个线程自己承载线程相关的数据,避免在方法中来回传递参数。

ThreadLocal 中使用一个 ThreadLocalMap.Entry 对象存储数据,Entry 是弱引用的子类,将 key(ThreadLocal 对象本身) 作为弱引用,value 是强引用存储,所以容易引起内存泄露,需要注意。

停止线程的方法

Thread#stop

强制停止线程,会立即释放资源,是不安全的,可能会导致以下清理性的工作得不到完成,如:关闭文件、关闭连接等。而且另一种情况是会对锁住的资源释放锁,导致数据得不到同步的处理,出现数据不一致问题。

Thread#run() 和 Thread#start() 方法的区别?
  1. start 方法启动线程,无需等待 run 方法体执行完毕而直接执行下面的代码。通过调用Thread类的start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。 然后通过此Thread类调用方法run()来完成其运行操作的, 这里方法run()称为线程体, 它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止, 而CPU再运行其它线程,在Android中一般是主线程。
  2. run() 方法当做普通方法调用,程序需要等待 run 方法体执行完才能继续往下执行。
Thread#interrupt()
  • 如果当前线程由于调用了 Object#wait、Thread#sleep()、Thread#join() 方法导致阻塞,调用此方法,线程的 interrupt 状态会被清理掉,并且会收到一个 InterruptedException。
  • 如果是通过 java.nio.channels.InterruptibleChannel 的 I/O 异常导致线程阻塞。channel 会被关闭,线程的 interrupt 状态会被重置,线程会收到一个 ClosedByInterruptException。
  • 不是上述任何条件,则仅会将线程的 interrupt 清除
  • 中断未运行的线程不会产生任何影响。
Thread#isInterrupted()

获取当前线程的中断状态。当前线程的 interrupted 状态不会被这个方法影响。

Thread.interrupted()

静态方法,获取当前线程的中断状态。当前线程的 interrupted 状态会被清理掉。

stop 方法已经被弃用了,因为不安全,可能会导致资源的错乱。interrupt 会给用户一个机会去处理关闭线程时的一些状态问题。

线程池

线程池 Executor,实际上是一个接口,ExecutorService 实现了这个接口。Java 为我们提供了一个 Executors 类,以及一些 API,方便我们创建一些常用的线程池。

线程池的创建于使用
  • newSingleThreadExecutor:创建一个单线程的线程池,如线程因异常结束,会创建一个新的,保证按照提交顺序执行。
  • newFixedThreadPool:创建一个固定大小的线程池,核心线程数等于最大线程数。根据提交的任务逐个增加线程,直到最大值保持不变。后续任务在 LinkedBlockingQueue 中等待。
  • newCachedThreadPool:创建一个可缓存的线程池,会根据任务自动新增和回收线程。核心线程数为 0,最大线程数为 Integer.MAX_VALUE。适合执行快速的,高并发的任务。比如 OkHttp 中的线程池就是用的这个。
  • newScheduledThreadPool:支持定时及周期性执行任务的需求
  • newWorkStealingPool:JDK8 新增的,根据所需的并行层动态创建和关闭线程,通过使用多个队列减少竞争。底层使用 ForkJoinPool 实现。优势在于可以充分利用多个 CPU,把一个任务拆分成多个小任务,放到多个处理器核心上并行执行;当多个“小任务”执行完,再将执行结果合并。
线程池的核心参数?

Executors 中创建线程池的接口,实际上底层都是创建了一个 ThreadPoolExecutor() 类,其中有一些核心参数

  • corePoolSize:核心线程数,线程池中会一直保留 corePoolSize 个线程
  • maximumPoolSize:最大线程数,线程池中允许的最大线程数
  • keepAliveTime:非核心线程的最大存活时间
  • unit:存活时间的单位
  • workQueue:BlockQueue 对象,执行任务前用于保存任务的队列,仅保持由 execute 方法提交的 Runnable 任务
  • threadFactory:执行程序创建新线程使用的工厂
  • handler:超出最大线程数和队列容量时,执行被阻塞时使用的处理程序。
线程池的几种 BlockingQueue?
SynchronousQueue

无界队列,直接提交,不存储元素。SynchronousQueue 本身的特性,每个插入操作,必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态。一般配合将线程池的最大线程数设为无界的(Integer.MAX_VALUE)使用,可以达到最大吞吐量。当有新任务来时,如果总线程数小于最大线程数,则创建新线程执行任务。此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。即保证任务按顺序执行。

LinkedBlockingQueue

LinkedBlockingQueue 分两种情况

  • 可以给定一个初始容量,此时 LinkedBlockingQueue 的行为与 ArrayBlockingQueue 一样。当核心线程数满了时,会将任务优先加入队列。当队列也满了,如果最大线程数没到阈值,则创建线程执行最新进来的任务。也就是不能保证任务执行顺序,后过来的可能先执行。如果最大线程是也满了,触发拒绝策略。
  • 如果不指定初始化容量,则 LinkedBlockingQueue 会默认使用 Integer.MAX_VALUE 初始化,即无界。此时因为队列永远不会满,则新来的任务总是会进入阻塞队列。

使用 LinkedBlockingQueue 无界队列需要注意,要防止任务疯涨,即任务添加的速度超过处理任务的时间,而且不断增加,内存容易爆掉。

ArrayBlockingQueue

ArrayBlockingQueue 的策略等于设置了初始阈值的 LinkedBlockingQueue。

线程池拒绝策略

线程拒绝类均实现 RejectedExecutionHandler 接口,JDK 内置四种线程拒绝策略。ThreadPoolExecutor 中默认的拒绝策略是 AbortPolicy。当前提交任务数大于(maxPoolSize + queueCapacity)时就会触发线程池的拒绝策略了。

CallerRunsPolicy(调用者运行策略)

功能:
当触发拒绝策略时,只要线程池没有关闭,就由提交任务的当前线程处理。

使用场景:
一般在不允许失败的、对性能要求不高、并发量较小的场景使用,因为线程池一般不会关闭,也就是提交的任务一定会被运行。但是由于是调用者线程自己执行,当多次提交任务,会阻塞后续任务执行,会影响性能和效率。

public static class CallerRunsPolicy implements RejectedExecutionHandler {

        public CallerRunsPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                r.run();
            }
        }
    }
AbortPolicy(中止策略)

功能:
触发拒绝策略时,直接抛出拒绝执行的异常,终止策略的意思就是打断当前执行流程

使用场景:
触发拒绝策略时,想要自己处理一些后续操作的时候。

public static class AbortPolicy implements RejectedExecutionHandler {

        public AbortPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());
        }
    }
DiscardPolicy(丢弃策略)

功能:
直接静悄悄的丢弃这个任务,不触发任何动作

使用场景:
如果你提交的任务无关紧要,你就可以使用它 。因为它就是个空实现,会悄无声息的吞噬你的的任务。所以这个策略基本上不用了。

public static class DiscardPolicy implements RejectedExecutionHandler {

        public DiscardPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        }
    }
DiscardOldestPolicy(弃老策略)

功能:
如果线程池未关闭,弹出队头元素,尝试执行这个任务

适用场景:
这个策略还是会丢弃任务,丢弃时也没有任何回调。但是丢弃的是老的未执行的任务。且待执行优先级较高的任务。发布消息,和修改消息,当消息发布出去后,还未执行,此时更新的消息又来了,这个时候未执行的消息的版本比现在提交的消息版本要低就可以被丢弃了。因为队列中还有可能存在消息版本更低的消息会排队执行,所以在真正处理消息的时候一定要做好消息的版本比较。

public static class DiscardOldestPolicy implements RejectedExecutionHandler {

        public DiscardOldestPolicy() { }

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                e.getQueue().poll();
                e.execute(r);
            }
        }
    }
使用无界队列的线程池会导致内存飙升吗?

会,newFixedThreadPool 使用了无界的阻塞队列 LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长,会导致队列中任务越积越多(任务添加速度大于执行速度,当设置的核心线程数满了后,会不停往队列中添加任务),导致机器内存不停飙升,最后 OOM。

关闭线程池
shutdown

让线程池进入 shutdown 状态,此状态不再接受新的任务,但会处理阻塞队列中的任务,阻塞队列为空,正在执行中的任务也为空时,进入 tidying 状态。

shutdownNow

对正在执行的任务全部发出 interrupt(),停止执行,对还未开始执行的任务全部取消,并且返回还没开始的任务列表。

execute 与 submit 的区别
  • execute 适用于不需要关注返回值的场景
  • submit 适用于需要关注返回值的场景。且通过 submit 的结果,Future#get 可以阻塞式的获得线程执行的结果

QA

QA1. 假设有 n 个网络线程,需要当 n 个网络线程完成之后再去做数据处理,你会怎么解决?

可以使用 thread.join() 方法,join 方法会阻塞,直到 thread 线程终止才返回。或者使用 CountDownLatch 类,CountDownLatch 的构造函数接收一个 int 参数作为计数器,每次调用 countDown 方法计数器减一。做数据处理的线程调用 await 方法阻塞,直到计数器为 0。

QA2. 如何设计一个高效的并发计数器

  • 当只有一个线程在更新值,多个线程在读的话,可以使用 volatile
  • 使用 synchronized:只要一个线程在访问,其他线程都阻塞,效率不高
  • 使用读写锁(ReadWriteLock):读锁是各线程共享的,写锁是线程独占的
  • 可以使用 AtomicInteger:使用处理器的 CAS 指令更新值,直接使用机器指令来设置值,对线程影响最小。但是如果和别的线程竞争失败,会继续自旋重试,如果高并发情况下,线程会在一个无限的循环内不断尝试赋值直到成功。
  • 可以尝试使用 Java8 之后新添加 API,如 LongAdder
    • 使用 xxxValue(IntValue) 获取对应值,使用 add() 方法设置值
    • 如果由于多线程竞争导致这个类的 CAS 操作失败,它会将要添加的值存到一个线程本地的 Cell 对象里,当 intValue 方法调用时,把这些 cell 的值加到总和里,减少了 CAS 的重试操作或者阻塞其他线程的情况

参考文章

synchronized(修饰方法和代码块)

Java 并发面试题

Java 中的锁

Java 锁的种类和区别

比 AtomicInteger 更高效的并发计数器 LongAdder

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值