面试之多线程与并发
1、Java中的同步容器类和缺陷
在Java中,同步容器主要包括2类:
1)Vector、HashTable。
2)Collections类中提供的静态工厂方法创建的类。Collections.synchronizedXXX()。
缺陷:
1)性能问题。
在有多个线程进行访问时,如果多个线程都只是进行读取操作,那么每个时刻就只能有一个县城进行读取,其他线程便只能等待,这些线程必须竞争同一把锁。
2)ConcurrentModificationException异常。
在对Vector等容器进行迭代修改时,但是在并发容器中(如ConcurrentHashMap,CopyOnWriteArrayList等)不会出现这个问题。
2、为什么说ConcurrentHashMap是弱一致性的?以及为何多个线程并发修改ConcurrentHashMap时不会报ConcurrentModificationException?
1)ConcurrentHashMap #get()
正是因为GET操作几乎所有时候都是一个无锁操作(GET中有一个readValueUnderLock调用,不过这句执行到的几率极小),使得同一个Segment实例上的PUT和GET可以同时进行,这就是GET操作是弱一致的根本原因。
2)ConcurrentHashMap #clear()
public void clear(){
for(int i=0;i<segments.length;++i)
segments[i].clear;
}
因为没有全局的锁,在消除完一个segment之后,正在清理下一个segment的时候,已经清理的segment可能又被加入了数据,因此clear返回的时候,ConcurrentHashMap中是可能存在数据的。因此,clear方法是弱一致的。
ConcurrentHashMap中的迭代器
在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。这就是ConcurrentHashMap迭代器弱一致的表现。
在这种迭代方式中,当iterator被创建后,集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。
总结,ConcurrentHashMap的弱一致性主要是为了提升效率,是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与HashTable和同步的HashMap一样了。
3、CopyOnWriteArrayList的实现原理
CopyOnWrite容器即写时复制的容器,也就是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器(改变引用的指向)。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写在不同的容器上进行,注意,写的时候需要加锁。
1)一下代码是向CopyOnWriteArrayList中add方法的实现,可以发现在添加的时候是需要加锁的,否则多线程写的时候会Copy出N个副本。
public boolean add(E e){
final ReentrantLock lock = this.lock;//加的是lock锁
lock.lock();
try{
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements,len+1);
newElements[len]=e;
setArray(newElements);//将原容器的引用指向新的容器;
return true;
}finally{
lock.unlock();
}
}
在CopyOnWriteArrayList里处理写操作(包括add,remove,set等)是先将原始的数据通过Arrays.copyof()来生成一份新的数据,然后再新的数据对象上进行写,写完后再将原来的引用指向到当前这个数据对象,这样保证了每次写都是在新的对象上。然后读的时候就是在引用的当前对象上进行读(包括get,iterator等),不存在加锁和阻塞。
CopyOnWriteArrayList中写操作需要大面积复制数组,所以性能肯定很差,但是读操作因为操作的对象和写操作不是同一个对象,读之间也不需要加锁,读和写之间的同步处理只是在写完后通过一个简单的“=”将引用指向新的数组对象上来,这个几乎不需要时间,这样读操作就很快很安全,适合在多线程里使用。
2)读的时候不需要加锁,如果读的时候有线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据(在原容器中进行读)。
public E get(int index){
return get(getArray(),index);
}
CopyOnWriteArrayList在读上效率很高,由于,写的时候每次都要将源数组复制到一个新的数组中,所以写的效率不高。
CopyOnWriteArrayList容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性的问题。
1)内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象。针对内存占用问题,可以
a. 通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。
b. 不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。
2)数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的数据,马上能读到,请不要使用CopyOnWrite容器!!
4、Java中堆和栈有什么不同?
栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存,用于存储本地变量,方法参数和栈调用,一个线程中存储的变量对其他线程是不可见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提升效率,县城会从堆中弄一个缓存到自己的栈,如果多个线程使用该变量就可能引发问题,这时volatile变量就可以发挥作用了。它要求线程从主存中读取变量的值。
5、Java中的活锁、死锁、饥饿有什么区别?
死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去,此时称系统处于死锁状态或系统产生了死锁。
饥饿:考虑一台打印机分配的例子,当有多个进程需要打印文件时,系统按照短文件优先的策略排序,该策略具有平均等待时间短的优点,似乎非常合理,但当短文件打印任务源源不断时,长文件的打印任务将被无限期地推迟,导致饥饿以至饿死。
活锁:与饥饿相关的另外一个概念称为活锁,在忙式等待条件下发生的饥饿,称为活锁。
不进入等待状态的等待称为忙式等待。另一种等待方式是阻塞式等待,进程得不到共享资源时将进入阻塞状态,让出CPU给其他进程使用。忙等待和阻塞式等待的相通之处在于进程都不具备继续向前推进的条件,不同之处在于忙等待的进程不主动放弃CPU,尽管CPU可能被剥夺,因而是低效的;而处于阻塞状态的进程主动放弃CPU,因而是高效的。
活锁的例子:如果事务T1封锁了数据R,事务T2又请求封锁R,于是T2等待。T3也请求封锁R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统有批准了T4的请求......T2可能永远等待(在整个过程中,事务T2在不断的重复尝试获取锁R)。
活锁的时候,进程是不会阻塞的,这会导致耗尽CPU资源,这是与死锁最明显的区别。
活锁指的是任务或执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。活锁和死锁的区别在于,处于活锁的实体是在不断地改变状态,所谓的“活”,而处于死锁的实体表现为等待;活锁有一定几率解开,而死锁是无法解开的。
避免活锁的简单方法是采用先来先服务的策略。当多个事务请求封锁同一数据对象时,封锁子系统按请求封锁的先后次序对事务排队,数据对象上的锁一旦释放就批准申请队列中第一个事务获得锁。
6、实现线程之间的通信?
当线程间是可以共享资源时,线程间通信是协调它们的重要的手段。
1)Object 类中wait()、notify()、notifyAll()方法。
2)用Condition接口。
Condition是被绑定到Lock上的,要创建一个Lock的Condition对象必须用newCondition()方法。在一个Lock对象里面可以创建多个Condition对象,线程可以注册在指定的Condition对象中,从而可以有选择性地进行线程通知,在线程调度上更加灵活。
在Condition中,用await()替换wait(),用signal替换notify(),用signalAll()替换notifyAll(),传统线程的通信方式,Condition都可以实现。调用Condition对象中的方法中,需要被包含在lock()和unlock()之间。
3)管道实现线程间的通信
实现方式:一个县城发送数据到输出管道流,另一个线程从输入管道流中读取数据。
基本流程:
1> 创建管道输出流PipedOutputStream pos 和管道输入流 PipedInputStream pis。
2> 将pos和pis匹配,pos.connect(pis)。
3> 将pos赋给输入信息的线程,pis赋给获取信息的线程,就可以实现线程间的通讯了。
缺点:
1> 管道流只能在两个线程之间传递数据。
线程consumer1 和 consumer2同时从pis中read数据,当线程producer往管道流中写入一段数据(1,2,3,4,5,6)后,每一个时刻只有一个线程能获取到数据,并不是两个线程都能获取到producer发送来的数据,因此一个管道流只能用于两个线程间的通讯。
2> 管道流只能实现单向发送,如果要两个线程之间互通讯,则需要两个管道流。
线程producer通过管道流向线程consumer发送数据,如果线程consumer想给线程producer发送数据,则需要新建另一个管道流pos1和pis1,将pos1赋给consumer1,将pis1赋给producer。
4)使用volatile 关键字。
见以前内容。
7、如何确保线程安全?
如果多个线程同时运行某段代码,如果每次运行结果和单线程运行的结果是一样的,而且其他变量的值也和预期的是一样的,就是线程安全的。
synchronized,Lock,原子类(如atomicInteger等),同步容器,并发容器,阻塞队列,同步辅助类(比如CountDownLatch,Semaphore,CyclicBarrier)。
8、多线程的优点和缺点?
优点:
1)充分利用CPU,避免CPU空转。
2)程序响应更快。
缺点:
1)上下文切换的开销
当CPU从执行一个线程切换到执行另外一个线程时,它需要先存储当前线程的本地数据,程序指针等,然后载入另外一个线程的本地数据,程序指针等,最后才开始执行。这种切换称为“上下文切换”。CPU会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另外一个线程。上下文切换并不廉价。如果没有必要,应该减少上下文切换的发生。
2)增加资源消耗
线程在运行时需要从计算机里面得到一些资源。除了CPU,线程还需要一些内存来维持它本地的堆栈。它也需要占用操作系统中一些资源来管理线程。
3)编程更加复杂
在多线程访问共享数据时,要考虑线程安全问题。
9、写出3条你遵循的多线程最佳实践
1)给线程起个有意义的名字。
2)避免锁定和缩小同步的范围
相对于同步方法我更喜欢同步块,它给我拥有对锁的绝对控制权。
3)多用同步辅助类,少用wait和notify。
首先,CountDownLatch,Semaphore,CyclicBarrier这些同步辅助类简化了编码操作,而用wait和notify很难实现对复杂控制流的控制。其次,这些类是由最好的企业编写和维护在后续的JDK中它们还会不断优化和完善,使用这些更高等级的同步工具你的程序可以不费吹灰之力获得优化。
4)多用并发容器,少用同步容器。
如果下一次你需要用到map,你应该首先想到用ConcurrentHashMap。
10、多线程的性能一定就优于单线程吗?
不一定,要看具体的任务以及计算机的配置。比如说:
对于单核CPU,如果是CPU密集型任务,如解压文件,多线程的性能反而不如单线程性能,因为解压文件需要一直占用CPU资源,如果采用多线程,线程切换导致的开销反而会让性能下降。如果是交互类型的任务,肯定是需要使用多线程的。
对于多核CPU,对于解压文件来说,多线程肯定优于单线程,因为多个线程能够更加充分利用每个核的资源。
11、怎么检测一个线程是否拥有锁?
在java.lang.Thread中有一个方法叫 holdsLock(Object obj),它返回true,如果当且仅当当前线程拥有某个具体对象的锁。
12、什么是线程调度器?
线程调度器是一个操作系统服务,它负责为Runnable状态的线程分配CPU时间。一旦我们创建一个线程并启动它,它的执行便依赖于线程调度器的实现。
13、Java程序如何停止一个线程?
建议使用“异常法”来终止线程的继续运行。在想要被中断执行的线程中,调用interrupted()方法,该方法用来检验当前线程是否已经被中断,即该线程是否被打上了中断的标记,并不使得线程立即停止运行,如果返回true,则抛出异常,停止线程的运行。在线程外,调用interrupt()方法,使得该线程打上中断的标记。