面试必备——Java多线程与并发(二)


1.synchroized相关(锁的是对象,不是代码)

(1)线程安全问题的主要原因

  • 存在共享数据(也称临界资源)
  • 存在多线程共同操作这些共享数据

解决:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作

(2)互斥锁的特性

互斥性(操作的原子性):即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另外一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另外一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致。

(3)获取的锁的分类

1)获取对象锁

两种用法

  • 同步代码块(synchronized(this),synchronized(类实例对象))),锁是小括号中的实例对象
  • 同步非静态方法(synchronized method),锁是当前对象的实例对象

2)获取类锁

 两种用法

  • 同步代码块(synchronized(类.class)),锁是小括号中的类对象
  • 同步静态方法(synchronized static method),锁是当前对象的类对象

 3)总结

  • 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块
  • 若锁住的是同个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞
  • 若锁住的是同个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞
  • 若锁住的是同个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步方法的线程会被阻塞,反之亦然
  • 同一个类的不同对象的对象锁互不干扰
  • 类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4一致,而由于一个类只有一把对象锁,所以用一个类的不同对象使用类锁将会是同步的
  • 类锁和对象锁互不干扰

(4)底层实现原理

1)对象在内存中的布局

  • 对象头
  • 实例数据
  • 对齐填充

2)实现基础

  • Java对象头

包含Mark Word(默认存储对象的hashCode,分代年龄,锁类型,锁标志位等信息,用来判断能否获得monitor锁)和类型指针(指向对象的类元数据,JVM通过这个指针确定该对象的哪个类的锁) Mark Word:由于对象头信息与对象自身定义的数据无关的额外存储成本,因此考虑到JVM的空间效率,它被设计成非固定的数据结构,以便存储更多有效的数据,它会根据自身的状态服用自己的存储空间,如下结构

  • Monitor:每个Java对象天生自带了一把看不见的锁,Monitor锁(内部锁),也称为管程或监视器锁,可以理解为一种同步工具(机制),通常它被描述为一个对象

Jvm会在Java每个对象创建时,给每个Java对象一把锁,也就是monitor锁,monitor里面定义了EntryList和WaitSet属性,也就是传说中的锁池和等待池, 在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor,c++代码实现的。 ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表,每个等待锁的线程都会被封装成ObjectWaiter对象,_owner指向持有ObjectMonitor对象的线程。      当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域,并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,_owner被恢复为null,count减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕后,将释放monitor锁并复位变量的值,以便其他线程进入获取monitor锁。

 

3)什么是重入

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入

4)为什么会对synchronize的嗤之以鼻

  • 早期版本中,synchronized属于重量级锁,依赖于Mutex Lock实现
  • 线程之间的切换需要从用户状态转换到核心态,开销较大

在Java6以后,对synchronize进行了很多优化,如自旋锁、自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等等,性能有了较大的提升。

5)自旋锁和自适应自旋锁

①自旋锁(java4已经引入,默认关闭,Java6默认开启)
  • 许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得
  • 通过让线程执行忙循环(类似While(true))等待锁的释放,不让出CPU
  • 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销
②自适应自旋锁
  • 自旋的次数不再固定
  • 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
③锁消除

更彻底的优化:JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁

1 public void add(String str1, String str2){
2    //StringBuffer是线程安全的,sb只会在append方法中使用,其他线程不可能引用
3    //不属于共享资源,JVM会自动消除内部的锁
4    StringBuffer sb = new StringBuffer();
5    sb.append(str1).append(str2);
6 }

View Code

④锁粗化

通过扩大对锁的范围,避免反复加锁和解锁

1 public static void run(){
2    int i =0;
3    StringBuffer buffer = new StringBuffer();
4    while(i<100){
5       buffer.append(i);
6    }
7 }

View Code 如上代码,在循环体里,每次StringBuffer调用append方法都会进行加锁解锁操作,而锁粗化就是在循环体外面加一个synchronize锁,避免循环体里频繁加锁解锁。

6)synchronized四种状态

  • 无锁、偏向锁、轻量级锁、重量级锁
  • 锁膨胀方向:无锁->偏向锁->轻量级锁->重量级锁
①偏向锁:减少同一线程获取锁的代价 CAS(Compare And Swap)
  • 大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得

  核心思想:   如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任务同步操作,即获得锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于Mark Word的                      ThreadID即可,这样就省去了大量有关锁申请的操作。

  • 不适用于锁竞争比较激烈的多线程场合
②轻量级锁
  • 轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁
  • 适应的场景:线程交替执行同步块
  • 若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁
③synchronize加锁的解锁的过程

加锁

  • 在代码进入同步块时,如果同步对象锁状态为无锁状态,虚拟机会在当前线程的栈帧中创建一个锁记录(Lock Record)的空间,用于存储当前对象的Mark Word的拷贝。
  • 拷贝对象头中的Mark Word到锁记录中。
  • 拷贝成功后,使用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针,并将锁记录里的owner指向Mark Word。
  • 如果更新成功,那么当前线程则获取了该对象的锁,并将Mark Word里的锁标志位设置为锁状态。
  • 如果更新失败,则判断Mark Word是否指向当前栈帧,如果是,则说明当前线程之前已经获取该锁,然后进入同步块继续执行,如果不是,则说明别的线程正在请求该锁,进行锁膨胀,根据锁状态,判断是进行自旋等待或者进入锁池中。

解锁

  • 利用CAS操作尝试把当前线程中复制的Mark Word对象替换当前对象中的Mark Word。
  • 如果替换成功,则同步完成
  • 如果替换失败,则说明在该线程执行过程中,有其他线程尝试过获取该锁(此时锁已膨胀),那就要释放锁的同时,唤醒被挂起的线程。
④锁的内存语义
  • 当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中;
  • 当线程获取锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
⑤汇总

2.ReentrantLock

(1)基本

  • 位于java.util.concurrent.locks包
  • 和CountDownLatch、FutureTask、Samaphore一样基于AQS实现
  • 能够实现比synchronized更细粒度的控制,如控制fairness
  • 调用lock()之后,必须调用unlock()释放锁
  • 性能未必比synchronized高,并且也是可重入的

(2)公平性的设置

  • ReentrantLock fariLock = new ReentrantLock(true);
  • 参数为true时,倾向于将锁赋予等待时间最久的线程;
  • 公平锁:获取锁的顺序按先后调用lock方法的顺序(慎用)
  • 非公平锁:抢占的顺序不一定,看运气
  • synchronized是非公平锁

(3)ReentrantLock将锁对象化

  • 判断是否有线程,或者某个特定线程,在排队等待获取锁
  • 带超时的获取锁的尝试
  • 感知有没有成功获取锁

3.Synchronized和ReentrantLock的区别

  • synchronized是关键字,ReentrantLock是类
  • ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
  • ReentrantLock可以获取各种锁的信息
  • ReentrantLock可以灵活地实现多路通知
  • 机制:sync操作Mark Word,lock调用Unsafe类的park()方法

 4.Java内存模型

即Java Memory Model,简称JMM,是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
简单说明: 由于JVM运行的实体是线程 ,而每个线程创建时JVM都会为其创建工作内存 (栈空间),工作内存是每个线程的私有数据区域 ,而JAVA内存模型规定所有变量都存储在主内存 ,主内存是共享内存区域 ,所有线程都可以访问 ,但线程对变量的操作必须在工作内存中进行 ,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量操作,操作完成后再将变量写入主内存 ,不能直接操作主内存中的变量 各个线程的工作内存中存储主内存中的变量副本拷贝 ,因此不同的线程之间无法访问对方的工作内存 ,线程间的通信必须通过主内存来完成

5.JMM中的主内存和工作内存

(1)JMM中的主内存

  • 存储Java实例对象
  • 包含成员变量、类信息、常量、静态变量等
  • 属于数据共享的区域,多线程并发操作时会引发线程安全问题

(2)JMM中的工作内存

  • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
  • 字节码行号指示器、Native方法信息
  • 属于线程私有数据区域,不存在线程安全问题

(3)JMM与Java内存区域划分是不同的概念层次

  • JMM描述的是一组规则(通过规则控制各个变量在共享区域和私有区域的访问方式),围绕原子性、有序性、可见性展开
  • 相似点:存在共享区域和私有区域(在JVM中,主内存属于共享区域,包含堆和方法区;而工作内存私有区域,包含程序计数器,虚拟机栈和本地方法栈)

(4)主内存与工作内存的数据存储类型以及操作方式归纳

  • 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中
  • 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
  • 成员变量、static变量、类信息均会被存储在主内存中
  • 主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成后刷新回主内存

6.JMM如何解决可见性问题

(1)问题描述

简单理解为,数据从主内存加载到缓存寄存器,然后运算结束写回主内存。 但当线程共享变量时,情况就变得很复杂,如果处理器对某个变量进行了修改,可能体现在该对象的内核缓存里,这是个本地状态,而在运行在其他内核的线程可能加载的是旧状态,很可能导致一致性的问题,从理论上说,多线程引入了复杂的数据依赖性,不管编译器处理器怎么做重排序,都必须尊重数据依赖性的要求,就打破了数据的正确性,这是JMM所要解决的问题
在执行程序的时候,为了提高性能,处理器和编译器常常会对指令进行重排序,但有一定的规则

(2)指令重排序需要满足的条件

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序

无法通过happens-before原则推导出来的,才能进行指令的重排序 JMM内部的实现通过是依赖于内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种happens-before的规则,与此同时,更多的复杂度在于,需要尽量确保各种体系结构处理器,能够提供一致的行为

(3)happens-before的八大原则

1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行于书写在后面的操作; 2.锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作; 3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作; 4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C; 5.线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作; 6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生; 7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值手段检测到线程已经终止执行; 8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

(4)happens-before的概念

如果两个操作不满足上述任意一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序; 如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。

(5)volatile:JVM提供的轻量级同步机制

  • 保证被volatile修饰的共享变量对所有线程总是可见的
  • 禁止指令重排序优化

(6)volatile可见性

被修饰的变量,总是对其他线程立即可见的 当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中 当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效

(7)内存屏障

  • 保证特定操作的执行顺序
  • 保证某些变量的内存可见性

(8)volatile如何禁止重排序化

  • 通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化
  • 强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本

(9)volatile和synchronized的区别

  • volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问变量,其他线程被阻塞住直到该线程完成变量操作为止
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

 7.CAS(Compare and Swap)

(1)概念

一种高效实现线程安全性的方法

  • 支持原子更新操作,适用于计数器,序列发生器等场景
  • 属于乐观锁机制,号称lock-free
  • CAS操作失败时由开发者决定是否继续尝试,还是执行别的操作

(2)思想

包含三个操作数:内存位置(V)、预期原值(A)和新值(B) 执行CAS操作时,将内存位置的值与预期原值比较,如果匹配,会将该位置的值更新为新值,否则处理器不做任何操作,这里的内存位置就是指主内存。

(3)CAS多数情况下对开发者来说是透明的

  • J.U.C的atomic包提供了常用的原子性数据类型以及引用、数组等相等原子类型和更新操作工具,是很多线程安全程序的首选
  • Unsafe类虽提供CAS服用,但因能够操纵任意内存地址读写而有隐患
  • Java9以后,可以使用Variable Handle API来替代Unsafe

(4)缺点

  • 若循坏时间长,开销很大
  • 只能保证一个共享变量的原子操作
  • ABA问题(解决:AutomicStampedReference,通过控制版本来解决)

 8.Java线程池

假设并发的线程比较多,但每个线程执行的时间很短,这样就会频繁创建线程,大大降低系统的效率,可能创建和销毁线程,消耗系统资源比执行的时间更多,这就需要线程池了。

(1)利用Executors创建不同的线程池满足不同场景的需求

1. newFixedThreadPool(int nThreads) 指定工作线程数量的线程池 2. newCachedThreadPool() 处理大量短时间工作任务的线程池 (1)试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;  (2)如果线程闲置的时间超过阈值,则会被终止并移出缓存; (3)系统长时间闲置的时候,不会消耗什么资源 3. newSingleThreadExecutor() 创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它 最大特点:可保证顺序去执行各个任务,并且在任意给定时间不会有多个线程是活动的 4. newSingleThreadScheduledExecutor0与newScheduledThreaPool(int corePoolSize) 定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程,前者如果线程异常结束,会有另一个线程取代它 5. newWorkStealingPool() 内部会构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序

(2)Fork/Join框架

1)分而治之

Java7提供的一个用于并行执行任务的框架,把大任务分割成若干个小任务并行执行,最总汇总每个小任务结果后得到大任务结果的框架。 可以更好利用多处理器带来的好处,它是为那些能够被递归的拆解成子任务的工作类型量身设计的。

2)工作窃取算法

Fork/Join把不同子任务放到不同的队列里,并为每个队列创建一个单独的线程来执行,会出现一种情况,有些线程任务执行快,有些线程任务执行慢,这样就会造成已完成任务的线程被闲置,太浪费资源了,这就有了工作窃取算法。 某个线程从其他队列中窃取任务来执行,一般就是指执行快的线程窃取执行慢的线程等待执行的任务,同时为了减少锁竞争,通常使用双端队列,被窃取线程永远从双端队列的头部拿任务执行,窃取线程从双端队列的尾部拿任务执行,如下图

(3)为什么要使用线程池

  • 降低资源消耗
  • 提高线程的可管理性

(4)Executor框架

是一个根据一组执行策略调用,调度,执行和控制的异步任务框架 目的是提供一种将任务提交,如何运行分离开来的机制

(5)J.U.C的三个Executor接口

  • Executor:运行新任务的简单接口,将任务提交和任务执行细节解耦
  • ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善
  • ScheduledExecutorService:支持Future和定期执行任务

(6)ThreadPoolExecutor

(7)ThreadPoolExecutor的构造函数

  • corePoolSize:核心线程数据
  • maximumPoolSize:线程不够用时能够创建的最大线程数
  • workQueue:任务等待队列
  • keepAliveTime:抢占的顺序不一定,看运气
  • threadFactory:创建新线程,Executors.defaultThreadFactory()

(8)handler:线程池的饱和策略

  • AbortPolicy:直接抛出异常,这是默认策略
  • CallerRunsPolicy:用调用者所在的线程来执行任务
  • DiscardOldestPolicy:丢弃队列中靠最前的任务,并执行当前任务
  • Doscar的Policy:直接丢弃任务
  • 实现RejectedExecutionHandler接口的自定义handler

(9)新任务提交execute执行后的判断

  • 如果运行的线程少于corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的;
  • 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时才创建新的线程去处理任务;
  • 如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理;
  • 如果运行的线程数据大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务

(10)线程池的状态

  • RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务
  • SHUTDOWN:不能接受新提交的任务,但可以处理存量任务
  • STOP:不再接受新提交的任务,也不能处理存量任务
  • TIDYTING:所有任务都已停止
  • TERMINATED:terminated()方法执行完后进入该状态

(11)状态转换图

(12)工作线程的生命周期

(13)线程池的大小如何选定

  • CPU密集型(针对计算的场景):线程数=按照核数或者核数+1设定
  • I/O密集型(处理较多等待任务):线程数=CPU核数*(1+平均等待时间/平均工作时间)

 

本站声明:网站内容来源于网络,如有侵权,请联系我们,我们将及时处理。

  • 分享:
评论
还没有评论
    发表评论 说点什么