Android中高级进阶开发面试题冲刺合集(二)

以下主要针对往期收录的面试题进行一个分类归纳整理,方便大家统一回顾和参考。本篇是第二集~

第一集面试题在这: Android中高级进阶开发面试题冲刺合集(一)

强调一下:【因篇幅问题:文中只放部分内容,全部文档需要的可找作者获取。

Java 集合

1.谈谈 Java 中 List、Set 以及 Map 的区别?

参考答案:

List:有序,可重复 set:无序,单一元素,集合 map:键值对

2.谈谈 ArrayList 和 LinkedList 的区别?

参考答案:

1.ArrayList是基于数组的数据结构,分配的是一段连续的内存空间。 新增数据的时候效率低,因为新增一个元素的时候都要考虑数组的容量,即扩容判断,而后进行新增元素; 修改数据同样根据index索引查找修改; 删除数据的时候效率低,因为是连续的内存空间,除删除最后一个元素外,删除任一元素都会使数组中的元素进行移动; 查找数据可根据index索引快速查找; 2.LinkedList是基于链表的数据结构 对比以上: 优点: 不需要考虑扩容的问题和连续的内存空间问题,在新增和删除的效率更好 缺点: 缺少了索引,降低了查找元素的效率,对应也降低了修改元素的效率

3.请说一下 HashMap 与 HashTable 的区别?

参考答案:

HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。

继承的父类不同

HashMap和Hashtable不仅作者不同,而且连父类也是不一样的。HashMap是继承自AbstractMap类,而HashTable是继承自Dictionary类。不过它们都实现了同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口

特点及优缺点比较:

  1. HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
  2. HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
  3. 另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。
  4. 由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
  5. HashMap不能保证随着时间的推移Map中的元素次序是不变的。

内部实现与操作问题

HashTable

  • 底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
  • 初始size为11,扩容:newsize = olesize*2+1
  • 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length

HashMap

  • 底层数组+链表实现,可以存储null键和null值,线程不安全
  • 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
  • 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
  • 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
  • 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
  • 计算index方法:index = hash & (tab.length – 1)

HashMap的初始值还要考虑加载因子:

  • 哈希冲突:若干Key的哈希值按数组大小取模后,如果落在同一个数组下标上,将组成一条Entry链,对Key的查找需要遍历Entry链上的每个元素执行equals()比较。
  • 加载因子:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小。
  • 空间换时间:如果希望加快Key查找的时间,还可以进一步降低加载因子,加大初始大小,以降低哈希冲突的概率。

HashMap和HashTable都是用hash算法来决定其元素的存储,因此HashMap和Hashtable的hash表包含如下属性:

  • 容量(capacity):hash表中桶的数量
  • 初始化容量(initial capacity):创建hash表时桶的数量,HashMap允许在构造器中指定初始化容量
  • 尺寸(size):当前hash表中记录的数量
  • 负载因子(load factor):负载因子等于“size/capacity”。负载因子为0,表示空的hash表,0.5表示半满的散列表,依此类推。轻负载的散列表具有冲突少、适宜插入与查询的特点(但是使用Iterator迭代元素时比较慢)

除此之外,hash表里还有一个“负载极限”,“负载极限”是一个0~1的数值,“负载极限”决定了hash表的最大填满程度。当hash表中的负载因子达到指定的“负载极限”时,hash表会自动成倍地增加容量(桶的数量),并将原有的对象重新分配,放入新的桶内,这称为rehashing。

HashMap和Hashtable的构造器允许指定一个负载极限,HashMap和Hashtable默认的“负载极限”为0.75,这表明当该hash表的3/4已经被填满时,hash表会发生rehashing。

“负载极限”的默认值(0.75)是时间和空间成本上的一种折中:

  • 较高的“负载极限”可以降低hash表所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的操作(HashMap的get()与put()方法都要用到查询)
  • 较低的“负载极限”会提高查询数据的性能,但会增加hash表所占用的内存开销程序猿可以根据实际情况来调整“负载极限”值。

结论

Hashtable和HashMap有几个主要的不同:线程安全以及速度。仅在你需要完全的线程安全的时候使用Hashtable,而如果你使用Java 5或以上的话,请使用ConcurrentHashMap。

4.说一说 ArrayList 的扩容机制?

参考答案:

初始容量为10 ,当添加第11个元素的时候,会扩容1.5倍,当添加到16个元素的时候扩容为15*1.5=22,以此类推。

5.HashMap 的实现原理?

参考答案:

HashMap 实际上是一个“链表散列”的数据结构,即数组和链表的结合体。它是基于哈希表的 Map 接口的非同步实现。

  • 数组:存储区间连续,占用内存严重,寻址容易,插入删除困难;
  • 链表:存储区间离散,占用内存比较宽松,寻址困难,插入删除容易;
  • Hashmap 综合应用了这两种数据结构,实现了寻址容易,插入删除也容易。

例如我们以下图为例,看一下 HashMap 的内部存储结构: Android中高级进阶开发面试题冲刺合集(二)_第1张图片
关于 HashMap 的存取过程,可参照下图:
Android中高级进阶开发面试题冲刺合集(二)_第2张图片

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容; 
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③; 
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals; 
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤; 
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可; 
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

6.请简述 LinkedHashMap 的工作原理和使用场景?

参考答案:

  • 查看LinkedHashMap源码发现是继承HashMap实现Map接口。也就是HashMap的方法LinkedMap都有。LinkHashMap与HashMap的主要区别是:LinkedHashMap是有序的,hashmap是无序的。LinkedHashMap通过维护一个双向链表实现有序,也正是因为要维护这个链表,内存上有更大的开销。

  • 补充下有序和无序

    • 我们说的无序是插入顺序和输出顺序不一致。
  • 补充一下链表结构和顺序结构:线性结构分为顺序结构,和链表结构。

    • 顺序结构:在内存中是一块完整有序内存。所以我们在查询的时候时候直接索引index,便可找到要查询的数据,速度非常快,缺点是插入删除慢。有点类似班级排队时(一列纵队),每个人都知道自己在第几个位置。老师只要说第三个位置,那这个同学立马知道老师要找的是自己。这时候要插入一个同学到第二个位置,所以之前第二个位置开始往后的每个同学的位置都要+1。所以比较慢。
    • 链表结构:通过结点头记录该结点的上一个结点和下一个下一个结点(就是传统的双链表,单链表就是只记录下一个结点,循环链表就是最后一个结点的下一个结点指向第一个结点)。正是因为这种关系,所以链表结构不需要一块完整的内存,而且插入删除相对快,但是查询相对慢。但是因为要维护结点头,所以内存开销相对大一点。有点类似于班级排队时,每个人虽然不知道自己的位置,但是知道自己前面是谁和后面是谁。当要插入一个同学b时到c前面时,只要c同学记住自己之前是a,现在换成b.b记住自己前面是a,后面是c。所以想对来说插入很快。删除类似。但是当老师按位置查询时,就要先从第一个开始计数,知道找到老师要找的数字。所以查询慢。

7.谈谈对于 ConcurrentHashMap 的理解?

参考答案:

并发集合常见的有 ConcurrentHashMap、ConcurrentLinkedQueue、ConcurrentLinkedDeque 等。并发集合位于java.util.concurrent 包 下 ,是 jdk1.5之 后 才 有 的。

在 java 中有普通集合、同步(线程安全)的集合、并发集合。普通集合通常性能最高,但是不保证多线程的安全性和并发的可靠性。线程安全集合仅仅是给集合添加了 synchronized 同步锁,严重牺牲了性能,而且对并发的效率就更低了,并发集合则通过复杂的策略不仅保证了多线程的安全又提高的并发时的效率ConcurrentHashMap 是线程安全的 HashMap 的实现,默认构造同样有 initialCapacity 和 loadFactor 属性,不过还多了一个 concurrencyLevel 属性,三属性默认值分别为 16、0.75 及 16其内部使用锁分段技术,维持这锁Segment 的数组,在 Segment 数组中又存放着 Entity[]数组,内部 hash 算法将数据较均匀分布在不同锁中。

put 操作:并没有在此方法上加上 synchronized,首先对 key.hashcode 进行 hash 操作,得到 key 的 hash 值。hash操作的算法和map也不同,根据此hash值计算并获取其对应的数组中的Segment对象(继承自ReentrantLock),接着调用此 Segment 对象的 put 方法来完成当前操作。 ConcurrentHashMap 基于 concurrencyLevel 划分出了多个 Segment 来对 key-value 进行存储,从而避免每次 put 操作都得锁住整个数组。在默认的情况下,最佳情况下可允许 16 个线程并发无阻塞的操作集合对象,尽可能地减少并发时的阻塞现象。

get(key) 首先对 key.hashCode 进行 hash 操作,基于其值找到对应的 Segment 对象,调用其 get 方法完成当前操作。而 Segment 的 get 操作首先通过 hash 值和对象数组大小减 1 的值进行按位与操作来获取数组上对应位置的HashEntry。

在这个步骤中,可能会因为对象数组大小的改变,以及数组上对应位置的 HashEntry 产生不一致性,那么 ConcurrentHashMap 是如何保证的?

对象数组大小的改变只有在 put 操作时有可能发生,由于 HashEntry 对象数组对应的变量是 volatile 类型的,因此可以保证如 HashEntry 对象数组大小发生改变,读操作可看到最新的对象数组大小。

在获取到了 HashEntry 对象后,怎么能保证它及其 next 属性构成的链表上的对象不会改变呢?

这点ConcurrentHashMap 采用了一个简单的方式,即 HashEntry 对象中的 hash、key、next 属性都是 final 的,这也就意味着没办法插入一个HashEntry对象到基于next属性构成的链表中间或末尾。这样就可以保证当获取到HashEntry对象后,其基于 next 属性构建的链表是不会发生变化的。

ConcurrentHashMap 默认情况下采用将数据分为 16 个段进行存储,并且 16 个段分别持有各自不同的锁Segment,锁仅用于 put 和 remove 等改变集合对象的操作,基于 volatile 及 HashEntry 链表的不变性实现了读取的不加锁。这些方式使得 ConcurrentHashMap 能够保持极好的并发支持,尤其是对于读远比插入和删除频繁的 Map而言,而它采用的这些方法也可谓是对于 Java 内存模型、并发机制深刻掌握的体现。 总结:ConcurrentHashMap 比起Hashmap,是线程安全的,比起HashTable是高效的。

Java 多线程

1.Java 中使用多线程的方式有哪些?

参考答案:

  • 继承Thread类,从写run方法
  • 实现Runnable接口,传递给Thread(runnable)构造函数。
  • 通过ExecutorService 线程池进行创建多线程
  • 通过FutureTask + Executors 实现带有返回值的多线程

2.说一下线程的几种状态?

参考答案:

1.创建状态。在生成线程对象,并没有调用该对象的start方法,这是线程处于创建状态。 2.就绪状态。当调用了线程对象的start方法之后,该线程就进入了就绪状态,但是此时线程调度程序还没有把该线程设置为当前线程,此时处于就绪状态。在线程运行之后,从等待或者睡眠中回来之后,也会处于就绪状态。 3.运行状态。线程调度程序将处于就绪状态的线程设置为当前线程,此时线程就进入了运行状态,开始运行run函数当中的代码。 4.阻塞状态。线程正在运行的时候,被暂停,通常是为了等待某个时间的发生(比如说某项资源就绪)之后再继续运行。sleep,suspend,wait等方法都可以导致线程阻塞。 5.死亡状态。如果一个线程的run方法执行结束或者调用stop方法后,该线程就会死亡。对于已经死亡的线程,无法再使用start方法令其进入就绪。

3.如何实现多线程中的同步?

参考答案:

大概是这样的几种方式:

  1. volatile-某种简单的逻辑下, 是可以的;
  2. synchronized;
  3. reentrantLock;
  4. cas=compare and swap(set), 就是 unsafe 类;
  5. handler(有点勉强, 他的实现本身依赖上面的一些技术)

4.谈谈线程死锁,如何有效的避免线程死锁?

参考答案:

一、死锁的定义

多线程以及多进程改善了系统资源的利用率并提高了系统 的处理能力。然而,并发执行也带来了新的问题——死锁。所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

二、死锁产生的原因

  1. 系统资源的竞争

通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在 运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可剥夺资源的竞争 才可能产生死锁,对可剥夺资源的竞争是不会引起死锁的。

  1. 进程推进顺序非法

进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都 会因为所需资源被占用而阻塞。

3)信号量使用不当也会造成死锁。

进程间彼此相互等待对方发来的消息,结果也会使得这 些进程间无法继续向前推进。例如,进程A等待进程B发的消息,进程B又在等待进程A 发的消息,可以看出进程A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。

  1. 死锁产生的必要条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。
/** 
* 一个简单的死锁类 
* 当DeadLock类的对象flag==1时(td1),先锁定o1,睡眠500毫秒 
* 而td1在睡眠的时候另一个flag==0的对象(td2)线程启动,先锁定o2,睡眠500毫秒 
* td1睡眠结束后需要锁定o2才能继续执行,而此时o2已被td2锁定; 
* td2睡眠结束后需要锁定o1才能继续执行,而此时o1已被td1锁定; 
* td1、td2相互等待,都需要得到对方锁定的资源才能继续执行,从而死锁。 
*/
public class DeadLock implements Runnable { 
  public int flag = 1; 
  //静态对象是类的所有对象共享的 
  private static Object o1 = new Object(), o2 = new Object(); 
  @Override
  public void run() { 
    System.out.println("flag=" + flag); 
    if (flag == 1) { 
      synchronized (o1) { 
        try { 
          Thread.sleep(500); 
        } catch (Exception e) { 
          e.printStackTrace(); 
        } 
        synchronized (o2) { 
          System.out.println("1"); 
        } 
      } 
    } 
    if (flag == 0) { 
      synchronized (o2) { 
        try { 
          Thread.sleep(500); 
        } catch (Exception e) { 
          e.printStackTrace(); 
        } 
        synchronized (o1) { 
          System.out.println("0"); 
        } 
      } 
    } 
  } 
  
  public static void main(String[] args) { 
      
    DeadLock td1 = new DeadLock(); 
    DeadLock td2 = new DeadLock(); 
    td1.flag = 1; 
    td2.flag = 0; 
    //td1,td2都处于可执行状态,但JVM线程调度先执行哪个线程是不确定的。 
    //td2的run()可能在td1的run()之前运行 
    new Thread(td1).start(); 
    new Thread(td2).start(); 
  
  } 
} 

三、如何避免死锁

在有些情况下死锁是可以避免的。三种用于避免死锁的技术:

  1. 加锁顺序(线程按照一定的顺序加锁)
  2. 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
  3. 死锁检测

加锁顺序

当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。

如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。看下面这个例子:

Thread 1:
  lock A 
  lock B
​
Thread 2:
   wait for A
   lock C (when A locked)
​
Thread 3:
   wait for A
   wait for B
   wait for C

如果一个线程(比如线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。

例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(译者注:获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。

按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。

加锁时限

另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(译者注:加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

以下是一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:

Thread 1 locks A
Thread 2 locks B
​
Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked
​
Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.
​
Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

在上面的例子中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利的获得这两个锁(除非线程2或者其它线程在线程1成功获得两个锁之前又获得其中的一些锁)。

需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。

此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果是10个或20个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多(或者非常接近以至于会出现问题)。 (译者注:超时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。)

这种机制存在一个问题,在Java中不能对synchronized同步块设置超时时间。你需要创建一个自定义锁,或使用Java5中java.util.concurrent包下的工具。写一个自定义锁类不复杂,但超出了本文的内容。后续的Java并发系列会涵盖自定义锁的内容。

死锁检测

死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。

当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,它需要递进地检测所有被B请求的锁。从线程B所请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有着。这是它就知道发生了死锁。

下面是一幅关于四个线程(A,B,C和D)之间锁占有和请求的关系图。像这样的数据结构就可以被用来检测死锁。
Android中高级进阶开发面试题冲刺合集(二)_第3张图片

那么当检测出死锁时,这些线程该做些什么呢?

一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(编者注:原因同超时类似,不能从根本上减轻竞争)。

一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

5.谈谈线程阻塞的原因?

参考答案:

1、线程执行了Thread.sleep(int n)方法,线程放弃CPU,睡眠n毫秒,然后恢复运行。

2、线程要执行一段同步代码,由于无法获得相关的同步锁,只好进入阻塞状态,等到获得了同步锁,才能恢复运行。

3、线程执行了一个对象的wait()方法,进入阻塞状态,只有等到其他线程执行了该对象的notify()或notifyAll()方法,才可能将其唤醒。

4、线程执行I/O操作或进行远程通信时,会因为等待相关的资源而进入阻塞状态。例如,当线程执行System.in.read()方法时,如果用户没有向控制台输入数据,则该线程会一直等读到了用户的输入数据才从read()方法返回。进行远程通信时,在客户程序中,线程在以下情况可能进入阻塞状态。

5、请求与服务器建立连接时,即当线程执行Socket的带参数的构造方法,或执行Socket的connect()方法时,会进入阻塞状态,直到连接成功,此线程才从Socket的构造方法或connect()方法返回。

6、线程从Socket的输入流读取数据时,如果没有足够的数据,就会进入阻塞状态,直到读到了足够的数据,或者到达输入流的末尾,或者出现了异常,才从输入流的read()方法返回或异常中断。输入流中有多少数据才算足够呢?这要看线程执行的read()方法的类型。

int read(); 只要输入流中有一个字节,就算足够。

int read(byte[] buff); 只要输入流中的字节数目与参数buff数组的长度相同,就算足够。

String readLine(); 只要输入流中邮一行字符串,就算足够。值得注意的是,InputStream类并没有readLine方法,在过滤流BufferedReader类中才有此方法。

7、线程向Socket的输出流写一批数据时,可能会进入阻塞状态,等到输出了所有的数据,或者出现异常,才从输出流的write()方法返回或异常中断。

8、调用Socket的setSoLinger()方法设置了关闭Socket的延迟时间,那么当线程执行Socket的close方法时,会进入阻塞状态,直到底层Socket发送完所有剩余数据,或者超过了setSoLinger()方法设置的延迟时间,才从close()方法返回。

6.请谈谈 Thread 中 run() 与 start() 方法的区别?

参考答案:

run() 和普通的成员方法一样,可以被重复调用。但是如果单独调用 run 方法,则不是在子线程中执行。

start() 这个方法只能被调用一次。调用这个方法后 程序会启动一个 新的线程来 执行 run 方法。注意 :调用start 后,线程处于可运行状态(并没有运行),一旦得到 cup 时间片,就开始执行run 方法,run 方法结束后,线程则立即终止。

7.说一下 synchronized 和 volatile 关键字的区别?

参考答案:

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

8.如何保证线程安全?

参考答案:

当多个线程要共享一个实例对象的值得时候,那么在考虑安全的多线程并发编程时就要保证下面3个要素:

原子性(Synchronized, Lock)

有序性(Volatile,Synchronized, Lock)

可见性(Volatile,Synchronized,Lock)

当然由于synchronized和Lock保证每个时刻只有一个线程执行同步代码,所以是线程安全的,也可以实现这一功能,但是由于线程是同步执行的,所以会影响效率。

下面是对3个要素的详细解释:

原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在Java中,基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

可见性: 指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取共享变量时,它会去内存中读取新值。

普通的共享变量不能保证可见性,因为普通共享变量被修改后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

更新主存的步骤: 当前线程将其他线程的工作内存中的缓存变量的缓存行设置为无效,然后当前线程将变量的值跟新到主存,更新成功后将其他线程的缓存行更新为新的主存地址。 其他线程读取变量时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。 有序性:即程序执行的顺序按照代码的先后顺序执行。 在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。 可以通过volatile关键字来保证一定的“有序性”。 当在处理并发编程的时候,只要程序满足了原子性,可见性和有序性,那么程序就不会发生脏数据的问题。

9.谈谈 ThreadLocal 用法和原理?

参考答案:

ThreadLocal用于保存某个线程共享变量:对于同一个static ThreadLocal,不同线程只能从中get,set,remove自己的变量,而不会影响其他线程的变量。

1、ThreadLocal.get: 获取ThreadLocal中当前线程共享变量的值。

2、ThreadLocal.set: 设置ThreadLocal中当前线程共享变量的值。

3、ThreadLocal.remove: 移除ThreadLocal中当前线程共享变量的值。

4、ThreadLocal.initialValue: ThreadLocal没有被当前线程赋值时或当前线程刚调用remove方法后调用get方法,返回此方法值。

10.谈谈 Java 线程中 notify 和 notifyAll 方法有什么区别?

参考答案:

区别 notify:只会唤醒等待该锁的其中一个线程。 notifyAll:唤醒等待该锁的所有线程。 既然notify会唤醒一个线程,并获取锁,notifyAll会唤醒所有线程并根据算法选取其中一个线程获取锁,那最终结果不都是只有一个线程获取锁吗?那JDK为什么还需要做出来这两个方法呢?这两种同步方法本质上会有什么区别?

这还要从对象内部锁的调度说起。

对象内部锁 其实,每个对象都拥有两个池,分别为锁池(EntrySet)和(WaitSet)等待池。

锁池:假如已经有线程A获取到了锁,这时候又有线程B需要获取这把锁(比如需要调用synchronized修饰的方法或者需要执行synchronized修饰的代码块),由于该锁已经被占用,所以线程B只能等待这把锁,这时候线程B将会进入这把锁的锁池。 等待池:假设线程A获取到锁之后,由于一些条件的不满足(例如生产者消费者模式中生产者获取到锁,然后判断队列为满),此时需要调用对象锁的wait方法,那么线程A将放弃这把锁,并进入这把锁的等待池。 如果有其他线程调用了锁的notify方法,则会根据一定的算法从等待池中选取一个线程,将此线程放入锁池。 如果有其他线程调用了锁的notifyAll方法,则会将等待池中所有线程全部放入锁池,并争抢锁。

锁池与等待池的区别:等待池中的线程不能获取锁,而是需要被唤醒进入锁池,才有获取到锁的机会。

11.什么是线程池?如何创建一个线程池?

参考答案:

线程池:顾名思义一堆线程在一起,核心成员是线程,线程是干什么的?当然是执行任务的!而线程池呢?当然也是! 而用线程池有什么好处呢?举个例子,你是一家公司的老板(进程),这家公司接到了N个任务,

  • 假如不使用线程池: 你每个任务都新建一个线程去处理,就相当于每个工作都招一个人来做(创建线程),做完直接开除掉(线程销毁),这显然有问题;
  • 使用线程池: 你手底下有或没有一些空闲的员工(执行完任务待命的线程),当任务来临时,先看看自己手底下有没有没事做的员工(空闲线程),有的话直接把任务交出去;没有的话你可以让任务等会执行(任务排队), 等待有线程空闲,或者你可以多招点人来处理(创建新线程),或者拒绝执行这个任务;当有人空闲时你可以让他等待新的任务来临,等待太长的话你可以把他开掉(线程销毁)

可见,使用线程池可以实现线程复用,以节省创建线程的所需的资源; 还有一种fork-join式线程池(任务抢占式线程池),适用于可分割的任务,大致实现和普通线程池差不多,但是其任务可以“抢占”: 比如之前很火的数一亿颗米作业,分成N个线程数,可能因为各种原因造成某个线程早早的就数完了属于这个线程任务的那部分,而其他线程可能还有一大堆的米没有数,

  • 使用普通线程池的话,这个线程就只是默默的看着,不会去继续帮忙;
  • fork-join式线程池,这个线程的任务执行完毕后还会帮忙一起数;

12.谈一谈 Java 中几种常见锁以及它们的使用场景?

参考答案:

常见锁:

  • synchronized
  • ReentrantLock
  • ReentrantReadWriteLock
  • AtomicInteger

13.谈一谈线程 sleep() 和 wait() 方法的区别?

参考答案:

sleep 是Thread类的方法,wait是Object类的方法 sleep不释放锁,wait释放锁 sleep不需要Synchronized ,wait需要Synchronized sleep不需要唤醒,wait需要唤醒(除wait(int time))

14.什么是悲观锁和乐观锁?

参考答案:

锁是为了避免自己在修改资源的时候,别人同时在修改,导致同一时间产生两份修改,不知道如何处理的情况而设置的独占资源,避免多个操作同时处理同一资源的技术。

乐观锁:默认为,某个线程在自己处理共享资源的时候,不会出现同一时刻来修改此资源的前提,只在处理完毕,最后写入内存的时候,检测是否此资源在之前未被修改。类似于读写锁的读锁就是乐观锁。

悲观锁:默认为,某个线程在自己处理共享资源的时候,一定会出现同一时刻来修改此资源,所以刚拿到这个资源就直接加锁,不让其他线程来操作,加锁在逻辑处理之前。类似,synchronized关键字,条件锁,数据库的行锁,表锁等就是悲观锁。

15.谈一谈 Java 线程安全的集合有哪些?各有什么特点?

参考答案:

  1. 早期的线程安全集合
  • Vector = 全部方法加 synchronized 的 ArrayList
  • HashTable = 全部方法加 synchronized 的 HashMap
  1. 包装工具类
  • Collections.synchronizedXXX() 在原集合的基础上添加了锁对象,集合中的每个方法都通过这个锁对象实现同步
  1. java.util.concurrent包
  • ConcurrentHashMap 1.7 分段锁技术,1.8 对table每行首元素加锁
  • CopyOnWriteXXXX 加了写锁,写的时候锁住的整个对象,读则可以并发执行
  1. 其他
  • Stack 继承了 Vector

16.Java 中为什么会出现 Atomic 类?试分析它的原理和缺点?

参考答案:

一. 我们经常会使用i++操作,大家都知道这个并不是线程安全的,这时通常会使用synchroized关键字来处理并发操作,在并发量不大的情况使用synchroized性能并不是特别高。在jdk1.6以前synchroized是重量级锁,无论有没有资源竞争都会对变量加锁,在jdk1.6之后引入了偏向锁和轻量级锁,效率才有了很大的提升。atomic类使用了cas的思想,只有真正资源竞争的时候才会有资源消耗,而且atomic是通过底层硬件指令集实现的,所以并发量不大的情况下性能更高。

二. 主要原理就是CAS(比较和交换), 涉及到三个值(V, O, N), V是内存中真正的值,O是加载到线程中的预期值,N是计算后的目标结果值,当计算出目标结果值时比较V和O是否相等,不相等代表V被其他线程改写过,那么将V重新赋值给O,然后重新计算目标值,再次重复上述步骤,这个称为自旋操作。 缺点:

  1. 存在ABA问题,因为每次都比较O和V的值,如果在比较之前V被多次改写过,最终的值还是之前的V,那么仅仅比较最终的V和O是无法知道这种情况的。
  2. 只能针对一个共享变量进行原子操作。
  3. 可以看到当V和O不等的时候就需要自旋操作,当并发数量很多,资源竞争激烈时,进行自旋操作等待的时间会很长,性能会大幅度降低,这时候使用其他锁会比较合适

17.说说 ThreadLocal 的使用场景?与 Synchronized 相比有什么特性?

参考答案:

ThreadLocal和Synchronized虽然都和多线程有关。 但是ThreadLocal是为了多线程时,每个线程对变量的独立访问.线程间该变量值互不影响.内部是由一个ThreadLocalMap,key为当前ThreadLocal的弱引用,value为变量值。 Synchronized则是另一个意思.多线程时通过同步锁实现多个线程同时只能有一个线程对变量/方法访问。

Java 虚拟机

1.请简要谈一谈 Java 中的垃圾回收机制?

参考答案:

垃圾回收机制: 当堆内存中的某块区域没有对象引用时,这个内存区域就会变成垃圾,等待垃圾回收器的回收,

强制垃圾回收: 程序只能控制一个对象不被任何引用变量引用,绝对不能控制它的回收。 System.gc(); Runntime.getTuntime().gc(); 上面两个方法会建议系统进行垃圾回收,但是系统也有可能不进行回收。 对象的复活可以通过 finalize()方法来实现

2.回答一下什么是强、软、弱、虚引用以及它们之间的区别?

参考答案:

  • 强引用StrongReference) 强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
  • 软引用SoftReference) 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存(下文给出示例)。 软引用可以和一个引用队列 ReferenceQueue 联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
  • 弱引用WeakReference) 弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。 弱引用可以和一个引用队列 ReferenceQueue 联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
  • 虚引用PhantomReference) “虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。 虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 ReferenceQueue 联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。

3.简述 JVM 中类的加载机制与加载过程?

参考答案:

java中的类加载机制

Java语言系统自带有三个类加载器:

  • Bootstrap ClassLoader 最顶层的加载类,主要加载核心类库,%JRE_HOME%\lib下的rt.jar、 resources.jar、charsets.jar和class等。另外需要注意的是可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。
  • Extention ClassLoader 扩展的类加载器,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还 可以加载-D java.ext.dirs选项指定的目录。
  • Appclass Loader也称为SystemAppClass 加载当前应用的classpath的所有类。

我们需要知道这三个加载器的加载顺序

  1. Bootstrap CLassloder
  2. Extention ClassLoader
  3. AppClassLoader

然后需要知道这个加载顺序具体执行策略 双亲委托机制

一个类加载器查找class和resource时,是通过“委托模式”进行的,它首先判断这个class是不是已经加载成功,如果没有的话它并不是自己进行查找,而是先通过父加载器,然后递归下去,直到Bootstrap ClassLoader,如果Bootstrap classloader找到了,直接返回,如果没有找到,则一级一级返回,最后到达自身去查找这些对象。这种机制就叫做双亲委托。

4.JVM、Dalvik、ART 三者的原理和区别?

参考答案:

1、JVM指Java虚拟机,能够运行Java字节码的虚拟机器,有多种实现

2、Dalvik虚拟机是Google设计用于Android平台的虚拟机,支持已转换为.dex格式的Java程序的运行。与JVM的区别:

  • Dalvik基于寄存器,JVM基于堆栈
  • Dalvik有自己的字节码,不使用Java字节码
  • Android2.2开始,Dalvik支持JIT即时编译

3、ART虚拟机是现行的Android虚拟机,与Dalvik的区别:

  • ART采用的是Ahead-of-time AOT预编译技术,Dalvik采用的是JIT即时编译技术
  • AOT预编译会在应用安装过程中,将所有的字节码编译成机器码,应用运行时无需实时编译了,直接调用即可
  • JIT即时编译在应用启动时,通过性能分析来优化代码的执行;在应用运行时,实时将字节码编译成机器码

所以,ART虚拟机提高了程序的运行效率;首次安装需要预编译,所以安装时间比Dalvik中略长,机器码占用空间大,应用占用空间也会略大。

5.请谈谈 Java 的内存回收机制?

参考答案:

在Java中,它的内存管理包括两方面:内存分配(创建Java对象的时候)和内存回收,这两方面工作都是由JVM自动完成的,降低了Java程序员的学习难度,避免了像C/C++直接操作内存的危险。但是,也正因为内存管理完全由JVM负责。

6.什么是 JMM?它存在哪些问题?该如何解决?

参考答案:

java内存模型:定义了共享内存系统中多线程程序读写操作行为的规范,Java内存模型也就是为了解决这个并发编程问题而存在的 怎么解决:内存模型解决并发问题主要采取两种方式,分别是限制处理器优化,另一种是使用了内存屏障。 而对于这两种方式,Java底层其实已经封装好了一些关键字,我们这边只需要用起来就可以了。 关于解决并发编程中的原子性问题,Java底层封装了Synchronized的方式,来保证方法和代码块内的操作都是原子性的; 而至于可见性问题,Java底层则封装了Volatile的方式,将被修饰的变量在修改后立即同步到主内存中。 至于有序性问题,其实也就是我们所说的重排序问题,Volatile关键字也会禁止指令的重排序,而Synchroinzed关键字由于保证了同一时刻只允许一条线程操作,自然也就保证了有序性。

你可能感兴趣的:(android,java,面试,Android进阶,性能优化)