面试Java基础问题汇总 part2

进程/线程总结性描述

从广义上说,Java平台提供的线程同步机制包括synchronized关键字、volatile关键字、final关键字、static关键字和一些相关的API,如Object.wait()/notify()等。

线程之间的通信方式:

  1. 同步 (synchronized和各种)
  2. while轮询
  3. wait/notify机制
  4. 管道通信(java.io.PipedInputStreamjava.io.PipedOutputStream)

从操作系统层面,进程间的同步机制包括临界区(Critical Section)互斥量(Mutex)信号量(Semaphore)事件(Event)

进程之间的通信方式:

  • 管道(Unix分管道(pipe)和命名管道(named pipe))
  • 信号(signal)
  • 消息队列
  • 信号量(Semaphore)
  • 共享内存
  • 内存映射
  • Socket

lock获取锁的方式

  • lock():获取锁,如被锁定则等待
  • tryLock():如未被锁定才会获取锁,直接返回true或false;
  • tryLock(long timeout, TimeUnit unit):获取锁,如果未被锁定,则最多等待timeout时间后返回获取锁状态。
  • lockinterruptibly()lockinterrupt():当前线程未被interrupt才获取锁。

synchronized关键字的底层原理

Synchronized关键字的底层原理属于JVM层面。

Synchronized关键字同步语句块使用的是monitorentermonitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令指示结束位置。

当执行monitorenter指令时,线程试图获取monitor的所有权(monitor对象存在于每个Java对象的对象头中,synchronized所便是通过这种方式获取锁的,也解释了为什么Java中任意对象可以作为锁的原因)。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行monitorexit指令后将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那么当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

Synchroized同步方法并没有使用monitorentermonitorexit,取而代之的是ACC_SYNCHRONIZED标识,该标识指明了这是一个同步方法。JVM通过ACC_SYNCHRONIZED访问标志来辨别一个方法是否为同步方法,从而执行相应的指令。

ThreadLocal

ThreadLocal用于实现每一个线程都有自己的专属本地变量。

如果创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本

Synchoronized和lock的区别

  • lock是一个接口,而synchronized是一个关键字,即synchronized是内置的语言实现。
  • 异常是否释放锁:
    synchronized在发生异常时候会自动释放占有的锁,因此不会死锁;
    lock发生异常的时候,不会主动释放占有的锁,可能会引起死锁,必须手动unlock(所以lock一般写在try catch finally代码块中,finally中写入unlock,以避免死锁)。
  • 是否响应中断
    lock等待锁过程中可以用interrupt来中断等待,而synchronized只能等待锁的释放,不能响应中断。
  • lock可以通过trylock来知道有没有获取锁,而synchronized不能。
  • 性能上来讲,如果竞争资源不激烈,两者的性能是差不多的,而当竞争特别激烈时,lock的性能要远远优于synchronized。可以根据具体情况具体选择。
  • synchronized使用Object对象本身的waitnotifynotifyAll调度机制,而lock可以使用Condition进行线程之间的调度。
类别 synchronized lock
存在层次 Java关键字,JVM层面上 是一个类
锁的释放 1.获取锁的线程执行完同步代码,释放锁;2.线程执行发生异常,JVM会让线程释放锁 在finally必须释放锁,否则可能会造成死锁
锁的获取 假设A线程先获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 分情况而定。lock有多个锁获取的方式,可以尝试获得锁,线程不用一直等待。
锁状态 无法判断 可以使用trylock判断
锁类型 可重入 不可判断状态 不可中断 非公平 可重入 可判断状态 可中断 可公平(也可不公平)
性能 少量同步 大量同步

Synchronized和ReentrantLock的区别

  1. 两个都是可重入锁。
  2. synchronzied依赖于JVM而ReentrantLock依赖于API。
  3. 由于synchronized在JDK1.6进行过大量优化,所以性能已经不是选择标准。
  4. ReentrantLocksynchronized增加了一些高级功能:
    • ReentrantLock可以通过lock.lockInterruptibly()来实现中断线程对锁的等待,改为去做其他事。
    • ReentrantLock可以指定是公平锁还是非公平锁(ReentrantLock(boolean fair));而synchronized只能是非公平锁。
    • synchronized关键字可以与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock也可以实现类似机制,需要借助Condition接口和newCondition()方法。线程可以注册在指定的Condition实例中从而可以有选择性的进行线程通知,在调度上更加灵活。而synchronized关键字notify()方法被通知的线程是由JVM选择的,执行notifyAll()方法时,相当于整个Lock对象中只有Condition实例,所有的线程都注册在它一个身上,notifyAll()方法会通知所有处于等待的线程,这样造成很大的效率问题,而Condition实例的singalAll()方法只会唤醒注册在该Condition实例上的线程。

volatile关键字

JDK1.2之前,Java的内存模型实现总是从主存(即共享内存中)读取变量,是不需要进行特别注意的。而在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器寄存器)中,而不是直接在主存中进行读写。这就可能造成了一个线程在主存中修改了一个变量的值,而另外一个线程还在继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。为了解决这个问题,我们可以声明变量为volatile,这指示JVM,这个变量是不稳定的,每次使用它都在主存中进行读取。

volatile关键字的主要作用就是保证变量的可见性然后还有一个作用是防止指令重排。

Synchronized关键字和Volatile关键字的区别

  • volatile关键字是线程同步的轻量级实现,所以volatile关键字性能肯定要比synchronized关键字性能要好。
  • volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。
    (Java SE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其他各种优化之后执行效率有显著提升,实际开发过程中使用synchronized关键字的场景还是更多一些。)
  • 多线程访问volatile关键字不会发生阻塞,而synchronized则可能发生阻塞。
  • volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程访问资源的同步性。

并发编程的三个重要特性

  1. 原子性:一个代码片段,要么所有的操作全部得到执行并且不会受到任何因素的干扰而中断,要么全部都不执行。使用synchronized可以保证代码片段的原子性。
  2. 可见性:当一个变量对共享变量进行了修改,那么另外的线程都是立即能看到修改后的最新的值。votaile关键字可以保证共享变量的可见性。
  3. 有序性:代码在执行过程中的先后顺序,Java在编译器以及运行期间的优化,代码的执行顺序未必的就是编写代码时候的顺序。volatile关键字可以禁止指令进行重排序优化。

sleep()和wait()的共同点和不同点

  • 两者都可以暂停线程执行。
  • sleep()没有释放锁,而wait()方法释放了锁。
  • wait()通常用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait()方法调用后,线程不会自动苏醒,需要别的线程调用同一对象的notify()或者notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒,或者可以使用wait(long timeout)超时后线程会⾃动苏醒。

为什么我们调⽤ start() ⽅法时会执⾏ run() ⽅法,为什么我们不能直接调⽤run() ⽅法?

start()方法会执行线程的相应准备工作,然后再执行run()方法的内容,这是真正的多线程工作,而直接运行run(),会把run()方法当作主线程下的普通方法执行,并不会在某个线程执行它,所以不是多线程工作。

总结: 调⽤ start() ⽅法⽅可启动线程并使线程进⼊就绪状态,⽽ run() ⽅法只是 thread 的⼀个普通⽅法调⽤,还是在主线程⾥执⾏。

双重校验锁实现单例模式(线程安全)

单例模式是Java中最简单的设计模式之一,它属于创建性模式,提供了一种创建对象的最佳方式。

简单地说,单例模式涉及到一个单一的类,且该类负责自己创建自己的对象,同时需要确保只有单个对象被创建,并且提供了访问其唯一对象的方式,可以直接访问到,不需要实例化该类的对象

public class Singlenton {
	private volatile static Singlenton uniqueInstance;
	private Singleton() {}
	public static Singlenton getUniqueInstance() {
		if(uniqueInstance == null) {
			synchronized (Singlenton.class) {
				if(uniqueInstance == null) {
					uniqueInstance = new Singlenton();
				}
			}
		}
		return uniqueInstance;
	}
}

volatile关键字可以禁止JVM指令重排,保证多线程环境下也能正常运行。

饿汉式、懒汉式等其他更详细的参见设计模式:单例模式 (关于饿汉式和懒汉式)

JDK1.6 之后的synchronized 关键字底层做了哪些优化,可以详细介绍⼀下这些优

化吗?

这里做个引子,需要详细去了解,面试中很有可能的问题是,你了解java的锁吗,请介绍一下?

偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁只能升级不能降级,这种策略是为了提高获得锁和释放锁的效率。

Atomic原子类

原子类,具有原子/原子操作特征的类。多线程执行的时候,原子类对象进行的操作不会被中断。

java.util.concurrent.atmoic是java提供的原子类的存放地址。

AtomicInteger类为例,它主要利用CAS+volatile和native方法来保证原子操作,从而避免synchronized的高开销。

AQS

AQS全称为(AbstractQueueSynchronizer),位于java.util.concurrent.locks包下面。

AQS是一个用来构建锁和同步器的框架,使用AQS能简单而且高效地构造出应用广泛的大量的同步器,比如ReentrantLockSemaphore,其他的诸如ReentrantReadWriteLockSynchronousQueueFutureTask等等都是基于AQS的。我们也可以使用AQS轻松容易地构造出符合自己需求的同步器。

AQS原理概览

AQS的核心思想是,如果被请求的共享资源空闲,则当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS使用CLH队列锁来实现,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,landia,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。 AQS将每条请求共享资源的线程封装成一个CLH锁队列的一个结点来实现锁的分配。

AQS原理图:
面试Java基础问题汇总 part2

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来实现获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

AQS对资源的共享方式

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁。
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。

ReentrantReadWriteLock可以被看作是组合式,因为ReentrantReadWriteLock允许多个线程对同一资源进行读,但同时又不允许多个线程对同一资源进行写。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要完成共享资源state的获取和释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。

AQS底层使用了模板方法模式:

  1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法(重写很简单,即对于共享资源state的获取和释放)。
  2. 将AQS组合在自定义同步组件中实现,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

这和我们以往通过实现接口的方式有很大区别,是模板方法模式很经典的一个运用。

模板模式(Template Pattern):一个抽象类公开定义了执行它的方法(模板),它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式实现。这种设计模型属于行为型模式。

一般来说,自定义同步器要么是独占方法,要么是共享方式,只需实现tryAcquire-tryReleasetryAcquireShared-tryRelaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock

AQS组件总结

  • Semaphore(信号量)-允许多个线程同时访问:synchoronizedReentrantLock都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch(倒计时器):CountDownLatch是一个同步工具类,用来协调多个线程的同步,这个工具通常用来控制线程等待,它可以让某一个线程等待至倒计时结束,再执行。
  • CyclicBarrier(循环栅栏):CyclicBarrier和CountDownLatch非常类似,它可以实现线程间的技术等待,主要应用场景和CountDownLatch类似,但是比CountDownLatch更加复杂和强大。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续工作。CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉CyclicBarrier我已经抵达屏障,然后被阻塞。

参考资料

JavaGuide面试突击版,百度可得最新版。这里修正了原文的一些错误和不严谨的地方。