面试总结之并发篇(下)

48 阅读8分钟

面试总结之并发篇(下)

说说自己是怎么使用synchronized关键字?

修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,所以如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁。

修饰代码块:指定加锁对象,对指定对象加锁,进入同步代码块之前要获得给定对象的锁。

总结:synchronized关键字加到static静态方法和synchronized(class)代码块上都是给class类上锁。synchronized关键字加到实例方法上是给对象实例上锁。尽量不要使用synchronized(String a),因为JVM中,字符串常量池具有缓存功能!

常用的线程池有哪些?

  • SingleThreadExecutor:创建一个单线程的线程池,此线程池保证所有任务的执行顺序按照任务的提交顺序执行
  • FixedThreadPool:创建固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小
  • CachedThreadPool:创建一个可缓存的线程池,此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
  • ScheduledThreadPool:创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。

锁的优化机制了解吗?

从jdk1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下它并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。

锁的状态由低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是由低到高,降级在一定条件下也是有可能发生的。

自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所以没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置。

自适应锁:自适应锁也是自旋锁,但是自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。

锁消除:锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。

锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。

偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,之后这个线程再次进入同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其它线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁,可以通过设置-XX:+UseBiasedLocking开启偏向锁。

轻量级锁:JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获取锁。

整个锁升级的过程非常复杂,我尽力去除一些无用的环节。简单来描述整个升级的机制。

简单点说,偏向锁就是通过对象头的偏向线程ID来对比,甚至都不需要CAS了,而轻量级锁主要就是通过CAS修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。

1677841566961

线程池核心线程数怎么设置呢?

分为cpu密集型和IO密集型

cpu密集型:这种任务消耗的主要是cpu资源,可以将线程数设置为N(cpu核心数)+1,比cpu核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停而带来的影响。一旦任务暂停,cpu就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用cpu的空闲时间。

IO密集型:这种任务应用起来,系统会用大部分的时间来处理IO交互,而线程在处理IO的时间段内不会占用cpu来处理,这时就可以将cpu交出给其他线程使用。因此在IO密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是:核心线程数=cpu核心数量*2.

java线程池中队列常用类型有哪些?

  • ArrayBlockingQueue是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
  • LinkedBlockingQueue是一个基于链表结构的阻塞队列,此队列按FIFO(先进先出)排序元素,吞吐量通常要高于ArrayBlockingQueue.
  • SynchronousQueue是一个不存储元素的阻塞队列。
  • PrioityBlockingQueue是一个具有优先级的无限阻塞队列。PriorityBlockingQueue也是基于最小二叉堆实现。
  • DelayQueue:
    • 只有当其指定的延迟时间到了,才能够从队列中获取到该元素。
    • DelayQueue是一个没有大小限制的队列
    • 因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者才会被阻塞。

说一下线程之间是如何通信的?

线程之间的通信有两种方式:共享内存和消息传递。

共享内存:在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。线程A和线程B之间如果要通信的话,那么久必须经历下面两个步骤:

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

消息传递:在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是:wait()和notify(),或者BlockingQueue.

CAS有什么缺点吗?

CAS的缺点主要有3点:

ABA问题:ABA问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。

java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。

循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。

只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。