亲们,作为程序员你可以不跳槽,但是不能不学习,本文针对多份面试情况,总结了面试Android岗必问的Java部分的基础,多维度分析了问题的原理,使用,以及特点,希望对正在面试或将要面试的你带来一点帮助。如有疑问可留言说明。
HashMap,HashTable,ConcurrentHashMap的内部实现方式都是什么?有什么区别?
HashMap
实现方式以及内部原理
- 底层数组+链表,HashMap基于哈希思想,实现对数据的读写。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,然后找到bucket位置来存储值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞时,对象将会储存在链表的下一个节点中。HashMap在每个链表节点中储存键值对对象。当两个不同的键对象的hashcode相同时,它们会储存在同一个bucket位置的链表中,可通过键对象的equals()方法来找到键值对。如果链表大小超过阈值(TREEIFY_THRESHOLD,8),链表就会被改造为树形结构。
包含的属性
- 容量(capacity)
- 初始化容量(initial capacity)
- size
- 负载因子(load factor)
- 负载极限(0 ~ 1)默认负载极限是0.75。
特性
- 基于AbstractMap
- 可以存null键和null值,get方法为空无法判断是键不存在还是值为null,可用containsKey()判断。
- 初始size为16,扩容newsize =oldsize*2,size是2的n次幂。
- 扩容后原来数组元素会依次重新计算存放位置,并重新插入。
- Map中元素数超过Entry数组的75%则触发扩容。
- 计算index方法:index=hash&(tab.length-1)等价于hash%(tab.length-1),但&预算效率高。
- 达到负载极限(size/capacity)会触发rehashing(重新散列),也就是扩容。
- 7.1 负载极限越高,HashMap对象占用的内存越小,但查询效率会低(同一个空间里面的元素数目就可能会增加,会增加查找的时间), hash碰撞会越频繁。
- 7.2 负载极限越低,查询性能会提高,但是HashMap的内存会增加
- 达到负载极限(size/capacity)会触发rehashing(重新散列),也就是扩容。
- 线程不安全
- 插入完数据在判断是否扩容,容易造成无效扩容。
- 线程不安全是因为无锁,因此使用效率高。
- 迭代器(Iterator)是fail-fast迭代器,其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM
HashTable
实现方式
- 底层数组+链表
包含属性
- 容量(capacity)
- 初始化容量(initial capacity)
- size
- 负载因子(load factor)
- 负载极限(0 ~ 1)默认负载极限是0.75。
特性
- 基于Dictionary类
- k/v均不能为null
- 初始size为11,扩容oldsize*2+1
- 计算index=(hash&0x7FFFFFFF)%tab.length
- 线程安全,但是效率低,因为每次操作数据都会将整个hash表锁住。
- jdk1.8之后也使用了fast-fail机制
ConcurrentHashMap
实现方式
- JDK1.7 Segment + HashEntry
- JDK1.8 Node + CAS + Synchronized
特性
-
- 线程安全,使用了锁分段技术来保证线程安全的。
- 1.1 数据分段存储,每段数据都有一把锁,当一个线程操作这段数据的时候,本段会锁住,但是其他段不受影响,默认是16段,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。
- Map分为N个Segment(段),效率提升N倍,默认提升16倍。并且读操作是不加锁的,因为HashEntry的value使用volatile修饰过,可以保证数据读取为最新数据。
- 允许多个修改并发进行,因为使用了锁分离技术。
- 例如size()方法,containsValue()方法需要跨Segment(段)操作,可能需要顺序锁定所有段,在顺序释放所有锁。
- 段内扩容,无需整个Map扩容,插入前扩容,可防止无效扩容。
- 比HashMap多了Segment,Segment是一个可重入锁(Segment继承了ReentrantLock)。
JDK1.7~1.8实现分析
-
put()方法实现(JDK1.7)
当执行put方法插入数据时,根据key的hash值,在Segment数组中找到相应的位置,如果相应位置的Segment还未初始化,则通过CAS进行赋值,接着执行Segment对象的put方法通过加锁机制插入数据.
如线程A和线程B同时执行相同Segment对象的put方法- 线程A通过tryLock()获取到锁并将HashEntry对象插入相应位置。
- 线程B获取锁失败,执行scanAndLockForPut(),在该方法中重复执行tryLock()(多处理器下重复64次,单处理器下重复1次),重复次数达到上线则lock()挂起。
- A线程完成后调用unLock()并通知B线程开始执行。
-
size()方法实现(JDK1.7)
背景:ConcurrentHashMap支持多线程并发插入数据,因此计算元素时可能会出现不准确情况。- 不加锁,连续计算每个Segment的元素个数,最多计算3次,如相邻两次结果相同则返回。
- 如相邻两次结果不同,则给每一个Segment加锁再计算。
-
put()方法实现(JDK1.8)
- 根据key的hash值,在Node数组找到对应的位置,如果Node未初始化,则通过CAS插入相应数据。
- 如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;
- 如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;
- 如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
- 如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount;
Java线程创建有几种方式?分别是什么?这几种创建方式的有啥不同?
- 继承Thread,new继承的Thread的子类创建线程对象,并通过.start方式启动线程。
- 实现Runnable接口,并通过new Thread(“接口实现类”)
- 通过Callable和Future,实现Callable接口和call方法(该方法为线程的执行体),通过FutureTask对象包装Callable实现类对象,FutureTask作为Thread对象的target并启动新线程,最后通过new Thread("FutureTask对象")。可调用FutureTask.get()阻塞线程并获取线程执行结束的返回值。
这三种创建线程的区别?
- 采用Runnable,Callable接口,线程类只实现了接口,同时可以继承其他类。
- 接口实现,代表多个线程共享一个target对象,适合多个相同线程处理同一份资源,可将代码数据分开,能较好体现面向对象思想
- 通过接口实现的线程类,获取当前线程只能通过Thread.currentThread();
Synchronized和ReentantLock锁内部原理?
Synchronized
Java中每个对象都可作为锁,线程视图访问同步代码时,必须先获取对象锁,退出或抛异常时释放锁。
原理
- 通过进入/退出Monitor对象实现代码块同步和方法同步。
- 代码块同步:在编译后将monitorenter指令插如同步代码开始处,将monitorexit指令插入同步代码结束处,指令通过反编译字节码可以查看,执行到monitorenter开始获取monitor所有权,即获得对象锁。
- 方法同步:方法内有ACC_synchronized标记,线程执行到该标记,获取对应锁,实现方法同步。
- 代码块/方法同步实现细节不同,但本质均是对monitor监视器的获取,每个对象都有自己的monitor,在执行同步代码块或同步方法时需要先获取monitor才能执行逻辑,没有获取到monitor的线程将会阻塞,并进入同步队列,状态变为BLOCKED,当其他线程释放monitor后,会通知同步队列,重新获取monitor。
举例
- public synchronized void method1//方法同步,只能锁住当前对象,不同线程调用这一个对象会互斥,不同线程调用不同对象没有效果。
- synchronized(this) //代码块同步,只锁当前对象。
- public synchronized static void method3//锁住该类所有对象的,所有对象调用该方法均互斥。
- synchronized(Test.class)//锁住Test类的所有对象,所有对象调用该代码均互斥
- sysnchronized(o)//锁住o对象。
ReentantLock
一个可重入的互斥锁,它具有与使用synchronized方法和语句所访问的隐式监视器锁相同的一些基本行为和语义。
通过Lock接口锁对象,Lock出现之前通过synchronized实现锁功能,后来Lock提供了和synchronized类似的同步功能,只是使用的时候需要显示的获取和释放锁。
缺点是缺少想synchronized那种隐式获取释放锁的便捷性,但是获得了手动获取释放的操作性,同时可支持中断获取锁,超时获取锁等功能。
举例
ReentrantLock lock = new ReetrantLock();
lock.lock() ;
-
重入锁
- 当一个线程获取到一个对象的锁后,再次请求该对象的锁依然可以获取到。(自己可以再次获取自己的内部锁),Java内置的Synchronized和ReetrantLock均是可重入锁。
-
公平锁
- CPU在调度线程中随机选一个线程,随机即无法保证先到先得(synchronized就是非公平锁),会导致有些线程永远无法执行,而公平锁就用来解决这个问题,保证线程按照时间的先后顺序去执行。
- 非公平锁效率会比较低,因为内部需要维护一个有序队列。ReentrantLock即可实现公平锁,通过构造函数new ReentrantLock(true)即可。
Synchronized和ReentantLock锁有什么区别?
- Lock是一个接口,synchronized是Java关键字,内置语言实现。
- synchronized如果发生异常时,会自动释放锁,而Lock不会自动释放,如果不手动释放会造成死锁。因此在finally块中释放。
- Lock可以操作等待线程中断,通过lockInterruptibly()方法,synchronized不行。
- Lock可限时,通过tryLock(long timeout, TimeUnit unit)指定等待的时间,超出这个时间以后会返回false并退出等待.
- Lock可以返回获取锁的状态,synchronized不行
- Lock可以提高多线程读操作的效率。
- synchronized是非公平锁,ReentrantLock可以设置为公平所/非公平锁
- 在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而 当竞争资源非常激烈时(即有大量线程同时竞争),此时ReentrantLock的性能要远远优于synchronized 。所以说,在具体使用时要根据适当情况选择。而JDK1.6后这俩性能差不多了。
死锁是怎么产生的?
- 互斥条件,一个资源只能被一个线程使用
- 请求与保持条件,一个线程因请求资源阻塞时,对已获得的资源保持不变。
- 不剥夺条件,线程使用的资源,在使用完之前,不能强行剥夺。
- 循环等待条件,若干线程形成首尾相接的循环等待资源关系。
死锁的有哪几种?
- 静态锁顺序死锁。ab两个方法都需要获得A锁和B锁,a方法获取到了A锁,等待B锁,b方法获取到了B锁在等待A锁。可通过控制锁的获取顺序解决该问题。
- 动态锁顺序死锁。由于锁是动态传入,因此循序颠倒会出现死锁,可通过System.identityHashCode方法根据锁对象的hashcode码判断顺序。
- 协同对象死锁。在一个有锁的方法中调用了另一个对象有锁的方法,容易造成死锁,可以通过开放调用。如果调用某个外部方法时不需要在有锁的逻辑中调用,我们称之为开放调用。简单说就是锁只锁需要加锁的逻辑,其他逻辑无需再锁逻辑中调用。
乐观锁和悲观锁有说明区别?
- 悲观锁认为每次操作数据时都会有人中途操作数据,因此每次操作前都上锁,并阻塞其他线程操作数据。直至操作完成。例如Java中的synchronized和ReentrantLock都为悲观锁。
- 乐观锁认为每次操作数据时都不会有人中途操作数据,所以每次都不会上锁,但是每次更新前都会判断下这个数据有没有人改动过。例如CAS算法机制。Java中的com.java.util.concurrent.atomic包下所有的原子变量类均使用了CAS。
- CAS用于多读场景,即冲突很少的场景,这样省去了锁的开销,还增大了吞出量。多写的场景,不建议使用CAS因为写操作发生冲突,会导致死循环retry,反而降低了性能,这种建议使用悲观锁。
乐观锁有什么弊端?
- ABA问题,线程1初次读取变量的值为A,并且准备赋值时还是A,那么就进行赋值操作将值改为B,但是赋值前,其他线程有可能改成过C,然后又改回了A让线程1误认为变量的值没变。(可通过增加版本号解决ABA问题,每次更新数据都将版本号+1,更新时需判断原始数据和版本号均一致才更新)
- 更新数据长时间不成功会导致循环次数增多,影响性能。
- 只能保证一个共享变量的原子操作。(可通过配合悲观锁,或者利用AtomicReference类保证将多个变量合并成一个共享变量操作)
并发编程的三大概念是什么?
- 原子性:即一个操作或多个操作要么全部执行,要么就都不执行,要么执行过程中就不会被任何因素打断。在Java中基本类型变量的读取和赋值操作是原子性操作。
- 可见性:多个线程访问一个变量,当一个线程改变了变量的值,其他线程立刻能看到修改后的值。在Java中通过volatile关键字保证可见性。
- 2.1 通过volatile修饰过的变量能保证被修改后立即更新到主内存。保证其他线程读取到新值。
- 2.2 通过synchronized和Lock也可以保证可见性,因为释放锁会强制将变量新值更新到主内存。
- 可见性:多个线程访问一个变量,当一个线程改变了变量的值,其他线程立刻能看到修改后的值。在Java中通过volatile关键字保证可见性。
- 有序性:完全按照代码的顺序执行。(处理器为提高程序运行效率会将代码优化重新排序执行。不一定保证代码顺序执行,重排不影响单线程执行,但可能影响多线程并发执行)
Volatile有什么特性?用来做什么?
- 使用volatile修饰过的变量可保持不同线程对这个变量的可见性。
- 禁止进行指令重排。
- volatile不能确保原子性
- volatile保证有序性
使用举例
- 状态标记量
volatile boolean flag = false;
//线程1
while(!flag){
doSomething();
}
//线程2
public void setFlag() {
flag = true;
}
- 单例模式中的double check
class Singleton {
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
-
分析
以上代码主要在于instance = new Singleton()这一句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事 - 给 instance 分配内存
- 调用 Singleton 的构造函数来初始化成员变量
- 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
- 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
线程池有什么优势?为什么使用线程池?
- 降低系统资源消耗,通过重用已存在的线程可降低线程的创建和销毁造成的消耗。
- 提高心痛响应速度,有任务到达,无需等待新线程创建即可执行。
- 方便线程的并发,管理,能有效的控制线程的创建,防止无限制创建线程,造成oom等不稳定情况,线程池可进行统一分配调优,提高资源使用率。
- 提供了定时定期以及可控线程数等功能的线程池。可更便捷的使用。
线程池有几种?分别是什么?都有什么区别?
- 线程池有4种,分别是
-
1. newFixedThreadPool
线程数固定的线程池,最大线程数就是核心线程数,没有非核心线程,线程处于空闲状态也不会回收,没有超时机制,采用LinkedBlockingQueue。
new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>())
-
2. newCachedThreadPool
通过Executors类的newCachedThreadPool()方法创建,该方法已经设置了线程池的核心线程数和总线程数,以及超时等待时间。
newCachedThreadPool() {
//核心线程数0,最大线程数MAX_VALUE,60秒钟超时等待,
//SynchronousQueue代表没有等待队列,每次都创建新线程。 线程空闲60秒后,线程将全部清空
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
-
3. newScheduledThreadPool
可用于定时执行的任务,初始化传入核心线程数,非核心线程数没有限制,并且非核心线程空闲后立马回收,可通过Executors.newScheduledThreadPool(int corePoolSize)创建ScheduledExecutorService 对象。
- 3.1 通过ScheduledExecutorService.schedule(Runnable command, long delay, TimeUnit unit)设置延迟一定时间执行Runnable。
- 3.2 通过schedule(Callable callable, long delay, TimeUnit unit)设置延迟一定时间执行Callable
- 3.3 通过scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)延迟一定时间后固定一个时间执行任务。执行间隔为上一个任务执行开始到下一个任务执行开始。
- 3.4 scheduleWithFixedDelay(Runnable command, long initialDelay, long delay,TimeUnit unit)和scheduleAtFixedRate方法类似,不同的是间隔计算从上一个任务执行结束到下一个任务执行开始的计时。 -
4. newSingleThreadExecutor
只有一个核心线程,没有非核心线程,线程忙碌时需排队等待,通过newSingleThreadExecutor()初始化。
线程池内线程数量分配多少合适?
- cpu密集类型任务,线程数需尽可能少,可配置N+1线程的线程池
- IO操作远远高于cpu的任务,可充分利用闲下来CPU,多创建线程,如2*N个线程。