1、Volatile是什么?
-
Volatile是java虚拟机提供的轻量级的同步机制。
-
Volatile具有三大特性:保证可见性、不保证原子性、禁止指令重排序。
(1)保证可见性
Java内存模型(JMM)中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在线程的工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存。不同的线程间无法访问对方的工作内存,线程间的通信必须通过主内存来完成(隐式进行),主内存的变量得到刷新后会通知每个线程,以保证每个线程拿到的是变量的最新值。这就是JMM的可见性,Volatile可以保证JMM的可见性。
(2)不保证原子性
Java内存模型的原子性是指一系列操作不能被打断,要么全部执行完要么不执行。Volatile不能保证JMM的原子性,比如对一个volatile修饰的int型变量num执行自增操作num++,在JVM执行的字节码层面,这一行代码要分为三步进行(获取num的值->将num和1相加->将得到的结果写回内存),当有多个线程执行这一操作的时候,不能得到预期结果,比如开启20个线程,每个线程执行1000次该操作,预期最后num的值应该为20000,而实际最终的值会小于20000。这也就说明了Volatile不能保证原子性。
如果想要保证原子性,除了采用synchronized锁定方法外,还可以使用原子类。(3)禁止指令重排序
出于优化性能的考虑,编译器和处理器会对我们写的代码进行指令重排序,在单线程环境中,指令重排序会考虑数据依赖性,重排序后执行的结果和预期结果一致,但是在多线程环境中,指令重排序有可能导致错误的结果,甚至多次执行会得到结果都不相同。
Volatile禁止指令重排序,实现的原理是:
在每个volatile变量的写之前插入一个StoreStore屏障,
在每个volatile变量的写之后插入一个StoreLoad屏障,
在每个volatile变量的读之后插入一个LoadLoad屏障,
在每个volatile变量的读之后插入一个LoadStore屏障。
2、多线程下安全的单例模式(双重检查锁定)
public class Singleton {
private static volatile Singleton instance = null;
private Singleton(){}
public static Singleton getInstance(){
if (instance == null){ // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
-
将instance声明为volatile变量的意义?
instance = new Singleton();这行代码创建了一个对象,在底层这一行代码可以分解成三行伪代码:
memory = allocate(); // 1、分配对象的内存空间 ctorInstance(memory); // 2、初始化对象 instance = memory; // 3、设置instance指向刚分配的内存地址
对于上面3行伪代码中的2和3之间,可能会重排序,在多线程的环境中,如果一个线程正在创建对象,并且按照1->3->2的顺序,又恰好在执行完3还没有执行2的时候,另外一个线程来调getInstance(),由于instance已经有了地址空间,所以不为null,因此直接返回当前的instance,而当前的instance还没有初始化,所以返回的instance是错的对象。
而将instance声明为volatile后,将会禁止2和3重排序,也就实现了线程安全。
3、CAS是什么?底层原理?有什么缺点?
CAS全称是Compare And Swap,它是一条CPU并发原语,它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
CAS并发原语体现在Java语言中就是sun.misc.Unsafe类中的各个方法。调用Unsafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。
如上图所示是CAS的一个应用:原子类AtomicInteger的getAndIncrement()方法的实现。
该方法的实现依靠的是Unsafe类的getAndAddInt(Object o, long l, int i)方法,l是内存偏移量,该方法的实现原理是:自旋获取对象o内存偏移位置为l处的变量值直到CompareAndSwap方法执行成功。
-
CAS的缺点
(1)如果CAS一直失败,自旋会大量消耗CPU资源;
(2)只能保证一个共享变量的原子操作;
(3)ABA问题。
4、ABA问题
- ABA问题的产生:
假设当前主内存中有一个变量的值为A,有两个线程同时对这个变量进行操作。刚开始时,线程1读取变量的值为A,线程2读取变量的值为2。线程2执行速度比线程1快,它先把变量的值更改为B,随后又把变量的值改回A,这个情况下变量的值中间有过变化,但最终值没有变,这个时候线程1调用CAS方法操作是成功的,这是一种狸猫换太子的现象,称为ABA问题。
- ABA问题的解决
ABA问题的解决方法是为当前变量增加一个版本号,每次修改时都要比较当前保存的变量的版本号和变量当前实际的版本号,如果版本号一致,并且当前值和预期值一致,才可以修改成功。我们可以借助AtomicStampedReference类实现这一功能,有效解决ABA问题。
5、不安全的集合类
-
集合类之并发修改异常:java.util.ConcurrentModificationException
-
ArrayList保证并发安全的解决方案:
(1)使用Vector代替ArrayList,Vector通过在方法上加synchronized关键字实现线程安全,但是synchronized比较重,Vector的效率很低,一般不采用。
(2)使用Collections.synchronizedList(new ArrayList())包装ArrayList,synchronizedList是Collections的一个内部类,他也是通过synchronized机制实现线程安全,只不过是在方法内使用synchronized锁一个互斥量synchronized (mutex)。
(3)使用CopyOnWriteArrayList类,CopyOnWrite意为写时复制,CopyOnWriteArrayList添加元素时,并不是直接在原有数组里添加元素,而是将数组复制一份出来,并且新数组的长度比原来增加1,然后在新数组的末尾追加元素。 -
HashSet保证并发安全的解决方案:
(1)Collections.synchronizedSet(new HashSet())。
(2)CopyOnWriteArraySet,底层封装了CopyOnWriteArrayList,HashSet底层是HashMap。 -
HashMap保证并发安全的解决方案:
(1)HashTable,效率太低,一般不用。
(2)Collections.synchronizedMap(new HashMap())。
(3)ConcurrentHashMap,采用了分段锁机制。
6、公平锁和非公平锁
- 公平锁:按照申请锁的顺序获得锁,先到先得。在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
- 非公平锁:并不按照顺序获取锁,而是上来就直接尝试获取锁,如果获取失败,就再采用类似公平锁的方式获取锁。
- Java中的ReentrantLock和synchronized默认都是非公平锁。
7、可重入锁、自旋锁、读写锁
-
(1)可重入锁:
可重入锁又称递归锁,指的是同一线程在外层方法获得锁之后,进入内层方法会自动获取锁,线程可以进入任何一个它已经拥有的锁所同步者的代码块。由于可重入锁底层会用一个变量记录当前线程获取锁的次数,因此写代码的时候要注意:一个线程获取锁和释放锁的次数要对等,这样才能保证该线程工作完成后,其他线程能顺利获得锁。
Java中的ReentrantLock和synchronized都是可重入锁。
可重入锁的作用是防止产生死锁。
-
(2)自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,因为线程阻塞是将获得的时间片资源释放掉,而自旋是依然保留着时间片,因此自旋锁的优点是减少线程上下文切换的资源消耗,缺点是循环会消耗CPU。
-
(3)读写锁
读写锁ReentrantReadWriteLock的读锁WriteLock和ReadLock都是可重入锁,即当前获取读锁或写锁的线程可再次获得相同的锁。
读锁ReadLock是共享锁,允许多个线程同时获取到读锁。
写锁WriteLock是独占锁,同一时刻只允许一个线程占有写锁。
8、Java中的并发工具类
-
(1)CountDownLatch
CountDownLatch允许一个或多个线程等待其他线程完成操作。
public class CountDownLatchTest { // 创建一个CountDownLatch,构造函数需要传入一个int类型参数count,表示等待线程需要等待的次数, // 这里传入一个2,也就是需要调用两次countDown方法,count的值才能减到0,然后等待线程才能执行, // countDown方法可以在一个线程里调用多次,也可以在多个线程里调用 static CountDownLatch c = new CountDownLatch(2); public static void main(String[] args) throws InterruptedException{ new Thread(() ->{ System.out.println(1); c.countDown(); //第一次调用countDown,count值减为1 System.out.println(2); c.countDown(); //第二次调用countDown,count值减为0 }).start(); c.await(); // 主线程调用CountDownLatch的await方法,需要等待count减到0,才能执行当前线程 System.out.println(3); } }
-
(2)CyclicBarrier
CyclicBarrier让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续执行。
public class CyclicBarrierTest { // 创建一个CyclicBarrier,构造函数需要传入一个int类型参数parties,表示屏障需要拦截的线程数, // 这里传入一个2,也就是当有两个线程到达屏障时才会放行,继续执行线程以后的方法 static CyclicBarrier c = new CyclicBarrier(2); public static void main(String[] args){ new Thread(() ->{ try { c.await(); //调用CyclicBarrier的await方法,当前线程到达屏障,等待其他线程 } catch (Exception e) { e.printStackTrace(); } System.out.println(1); }).start(); try{ c.await(); // 主线程调用CyclicBarrier的await方法,当前线程到达屏障,等待其他线程 } catch (Exception e){ e.printStackTrace(); } System.out.println(2); } }
-
(3)Semaphore
Semaphore(信号量)用来控制同时访问特定资源的线程数量。
public class SemaphoreTest { // 创建一个Semaphore,构造函数需要传入一个int类型参数permits,许可证的数量,即允许同时访问的线程数量, // 这里可以模拟一个停车场停车的场景,传入参数10,表示停车场共有10个停车位 static Semaphore c = new Semaphore(10); public static void main(String[] args){ for (int i = 1; i <= 30; i++) { // 模拟当前共有30辆车 new Thread(() -> { try{ c.acquire(); //当前线程获得许可证(抢到停车位) System.out.println(Thread.currentThread().getName() + "号车\t抢到车位"); TimeUnit.SECONDS.sleep(3); System.out.println(Thread.currentThread().getName() + "号车\t停车3秒后离开车位"); }catch (Exception e){ e.printStackTrace(); }finally { c.release(); //当前线程归还许可证(离开停车位) } },String.valueOf(i)).start(); } } }
-
CountDownLatch和CyclicBarrier的区别?
(1)CountDownLatch做的是一种减操作,每当一个点完成操作,count的值减一,直到count的值减到0,等待的线程才开始执行;CyclicBarrier做的是一种加操作,每当一个线程到达同步点(屏障),统计线程数量加一,直到统计数量和传入的参数parties相等才将所有的线程放行。
(2)CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置。所以CyclicBarrier能够处理更为复杂的业务场景。
9、阻塞队列
Java中的阻塞队列(BlockingQueue)有7个实现类,它们分别是:
- ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列
- LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列(默认和最大长度为Integer.MAX_VALUE,因此可以看做无界队列)
- PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列
- DelayQueue:一个使用优先级队列实现的无界阻塞队列
- SynchronousQueue:一个不存储元素的阻塞队列
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列
其中字体加粗的三个ArrayBlockingQueue、LinkedBlockingQueue和SynchronousQueue需要重点掌握,它们是线程池的重要组成结构。
阻塞队列在队列为空的时候,不允许消费者从队列中取元素,同样地,在队列满的时候,不允许生产者往队列中存元素。当这两种情况发生时,阻塞队列有四种处理方式:
方法 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
移除方法 | remove() | poll() | take() | poll(time, unit) |
查看队首元素 | element() | peek() | 无 | 无 |
正常情况下,插入元素成功,返回true;移除和查看元素成功,返回队首元素。
不正常情况下:
- 抛出异常:队列满时插入元素会抛出IllegalStateException,队列空时取元素会抛出NoSuchElementException。
- 返回特殊值:队列满时插入元素会返回false,队列空时取元素会返回null。
10、生产者/消费者模式的演变
11、synchronized和Lock的区别
-
(1)原始构成
synchronized是关键字,属于JVM层面,底层通过monitor对象来完成,monitorenter和monitorexit分别是进入和退出同步代码块的指令(wait/notify等方法也依赖于monitor对象,因此只有在同步代码块里才可以使用);
Lock是具体类(java.util.concurrent.locks.Lock),是API层面的锁。 -
(2)使用方法
synchronized不需要用户手动去释放锁,当synchronized代码执行完后系统会自动让线程释放对锁的占用;
Lock需要用户去手动释放锁,若没有主动释放锁,有可能导致死锁。(lock()和unlock()方法需要配合try/finally语句块来完成) -
(3)等待是否可中断
synchronized不可中断,除非抛出异常或者正常运行完成;
Lock可中断,1. 设置超时方法tryLock(long timeout,TimeUnit unit);2. lockInterruptibly()放代码块中,调用interrupt()方法。 -
(4)加锁是否公平
synchronized是非公平锁;
Lock两者都有实现,默认是非公平锁。 -
(5)锁绑定多个条件Condition
synchronized没有;
Lock可以绑定多个Condition对象,用来分组唤醒线程,实现精确唤醒,而不是像synchronized那样要么随机唤醒(notify),要么全部唤醒(notifyAll)。
12、Java中的线程池
-
使用线程池的优势:
(1)降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
(2)提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即执行。
(3)提高线程的可管理性。使用线程池可以对线程资源进行统一的分配、调优和监控。 -
Executor框架的体系
(1) Executors:是一个工具类(类似于Arrays、Collections),其内部封装了多个方法用来返回不同类型的ExecutorService,底层是通过调用ThreadPoolExecutor的构造方法返回一个ThreadPoolExecutor对象,通过传入的参数不同创建不同的线程池,常用的有三种:- FixedThreadPool:线程数固定的线程池。核心线程数和最大线程数都等于传入的线程数,线程一旦创建不会销毁,因此keepAliveTime参数失效。任务队列采用LinkedBlockingQueue。
- SingleThreadExecutor:只创建一个工作线程。核心线程数和最大线程数都为1,keepAliveTime参数也失效,任务队列使用LinkedBlockingQueue。
- CachedThreadPool:根据需要创建新线程的线程池,核心线程数为0,最大线程数为Integer.MAX_VALUE,多余线程存活时间为60秒,而任务队列采用SynchronousQueue。也就是说有任务提交并且没有空闲线程那么就创建新线程,线程空闲时间超过1分钟就销毁该线程。
(2)ThreadPoolExecutor:线程池的核心实现类,构造函数有7大参数:
- corePoolSize:线程池的核心线程数。每提交一个新任务就创建一个核心线程,即使当前有空闲的核心线程能够执行该任务也会创建。直到核心线程数达到corePoolSize就不再创建了。
- maximumPoolSize:线程池允许创建的最大线程数。
- keepAliveTime:多余的空闲线程的存活时间。当线程池中的线程数大于corePoolSize时,会把空闲时间超过该参数的空闲线程销毁,直到线程池中只剩下corePoolSize个线程。
- unit:keepAliveTime参数的单位。
- workQueue:任务队列,保存等待执行的任务的阻塞队列。
- threadFactory:用于设置创建线程的工厂,一般用默认即可。
- handler:拒绝策略,当任务队列已满并且工作线程大于等于最大线程数时所采取的用于处理提交的新任务的策略。
- FixedThreadPool:线程数固定的线程池。核心线程数和最大线程数都等于传入的线程数,线程一旦创建不会销毁,因此keepAliveTime参数失效。任务队列采用LinkedBlockingQueue。
-
线程池的底层工作原理
-
四种拒绝策略
(1)AbortPolicy(默认):直接抛出RejectedExecutionException异常。
(2)CallerRunsPolicy:只用调用者所在线程来运行任务。
(3)DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
(4)DiscardPolicy:不处理,丢弃掉 -
实际开发中的线程池选择
(1)虽然Executors已经为我们准备好了三种线程池,但是实际开发中一般都不会使用,也就是一般不通过调用Executors的方法生成线程池,而是通过手动传入参数利用ThreadPoolExecutor的构造方法生成线程池。这是因为Executors创建的三种线程池并不符合实际应用情况,比如说,采用LinkedBlockingQueue作为任务队列,虽然它的定义是一个有界队列,但默认长度是Integer.MAX_VALUE,这个值很大,如果并发量非常高,容易导致OOM发生,实际开发中要自己设定阻塞队列的大小,并根据情况选择拒绝策略。
(2)对于CPU密集型任务,线程的数量应配置得较少,一般可以设置CPU核数+1;而对于IO密集型任务,线程的数量应配置得尽可能多,合理的配置:CPU核数/(1-阻塞系数),其中阻塞系数的范围0.8~0.9。
13、多线程实现的四种方式
-
(1)继承Thread类,重写run方法
public class ThreadDemo01 extends Thread{ public ThreadDemo01(){ //编写子类的构造方法,可缺省 } public void run(){ //编写自己的线程代码 System.out.println(Thread.currentThread().getName()); } public static void main(String[] args){ ThreadDemo01 threadDemo01 = new ThreadDemo01(); threadDemo01.setName("我是自定义的线程1"); threadDemo01.start(); System.out.println(Thread.currentThread().toString()); } }
-
(2)实现Runnable接口,重写run方法,实现Runnable接口的实现类的实例对象作为Thread构造函数的target
public class ThreadDemo02 { public static void main(String[] args){ System.out.println(Thread.currentThread().getName()); Thread t1 = new Thread(new MyThread()); // Runnable是函数式接口,这里也可以使用Lambda表达式 t1.start(); } } class MyThread implements Runnable{ @Override public void run() { // TODO Auto-generated method stub System.out.println(Thread.currentThread().getName()+"-->我是通过实现接口的线程实现方式!"); } }
-
(3)通过Callable和FutureTask创建线程(可以有返回值)
public class ThreadDemo03 { /** * @param args */ public static void main(String[] args) { // TODO Auto-generated method stub Callable<Object> oneCallable = new Tickets<Object>(); FutureTask<Object> oneTask = new FutureTask<Object>(oneCallable); Thread t = new Thread(oneTask); System.out.println(Thread.currentThread().getName()); t.start(); } } class Tickets<Object> implements Callable<Object>{ //重写call方法 @Override public Object call() throws Exception { // TODO Auto-generated method stub System.out.println(Thread.currentThread().getName()+"-->我是通过实现Callable接口通过FutureTask包装器来实现的线程"); return null; } }
-
(4)通过线程池创建线程
public class ThreadDemo04{ private static int POOL_NUM = 10; //线程池数量 /** * @param args * @throws InterruptedException */ public static void main(String[] args) throws InterruptedException { // TODO Auto-generated method stub ExecutorService executorService = Executors.newFixedThreadPool(5); for(int i = 0; i<POOL_NUM; i++) { RunnableThread thread = new RunnableThread(); //Thread.sleep(1000); executorService.execute(thread); } //关闭线程池 executorService.shutdown(); } } class RunnableThread implements Runnable { @Override public void run() { System.out.println("通过线程池方式创建的线程:" + Thread.currentThread().getName() + " "); } }
14、死锁的产生原因和故障排查
-
产生死锁的四个必要条件:
(1)互斥条件:一个资源每次只能被一个进程使用。
(2)占有且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3)不可强行占有:进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
-
系统出现故障,怎么排查?
(1)先通过 "jps -l"命令查看虚拟机中正在运行的进程,找到可能出问题的线程对应的进程编号。
(2)通过 “jstack 进程编号” 命令查看当前进程的运行状况。