基础概念
线程的生命周期有哪些状态?它们是如何转换的?
答案:线程的生命周期有以下六种状态:
新建(New):线程被创建但尚未启动,此时线程对象已被分配内存空间,相关属性已被初始化。
就绪(Runnable):线程调用start()方法后进入此状态,表明线程已准备好运行,等待系统调度获取 CPU 资源。
运行(Running):线程获取到 CPU 资源,正在执行run()方法中的代码逻辑。
阻塞(Blocked):线程因某些原因暂停执行,放弃 CPU 使用权。如等待获取锁、执行wait()方法进入等待状态、执行 I/O 操作等。
超时等待(Timed Waiting):线程进入等待状态,但有指定的等待时间。例如通过Thread.sleep(long millis)、wait(long timeout)等方法进入此状态,时间到期后会自动返回就绪状态。
终止(Terminated):线程执行完run()方法中的代码,或者因异常等原因提前结束,线程进入终止状态,此时线程的生命周期结束。
状态转换:新建状态的线程调用start()方法进入就绪状态;就绪状态的线程被 CPU 调度选中进入运行状态;运行状态的线程执行wait()方法或等待获取锁等情况会进入阻塞状态,执行Thread.sleep(long millis)等方法会进入超时等待状态;阻塞状态和超时等待状态的线程在满足相应条件(如被notify()唤醒、获取到锁、等待时间结束等)后会回到就绪状态;运行状态的线程执行完run()方法或出现异常等会进入终止状态。
同步与锁
synchronized关键字的作用是什么?它是如何实现同步的?
答案:synchronized关键字用于实现线程之间的同步,确保在同一时刻只有一个线程能够访问被 synchronized修饰的代码块或方法,从而保证数据的一致性和完整性。
当一个线程访问被synchronized修饰的代码块或方法时,它会先获取对象的锁(如果是静态方法,则获取类的锁)。如果锁已经被其他线程持有,那么当前线程会进入阻塞状态,直到获取到锁。在获取到锁后,线程才能执行相应的代码。当线程执行完同步代码块或方法后,会释放锁,以便其他线程可以获取锁并执行同步代码。
说说ReentrantLock和synchronized的区别。
答案:
实现机制:synchronized是 Java 语言的关键字,由 JVM 底层实现;ReentrantLock是 Java.util.concurrent 包中的类,通过代码实现。
锁的获取与释放:synchronized在代码块或方法执行完后自动释放锁;ReentrantLock需要手动调用unlock()方法释放锁,通常在finally块中进行,以确保锁一定会被释放,否则可能导致死锁。
可重入性:两者都具有可重入性,即同一个线程可以多次获取同一个锁。
公平性:synchronized是非公平锁,线程获取锁的顺序不确定;ReentrantLock可以通过构造函数参数指定是否为公平锁,公平锁会按照线程请求锁的顺序分配锁,减少线程饥饿现象,但会降低一定的性能。
功能特性:ReentrantLock提供了更多的功能特性,如可以尝试获取锁(tryLock()方法)、可中断地获取锁(lockInterruptibly()方法)等,而synchronized不具备这些功能。
什么是死锁?如何避免死锁?
答案:死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的状态,若无外力作用,这些线程将永远无法继续执行。例如,线程 A 持有资源 1 并等待资源 2,而线程 B 持有资源 2 并等待资源 1,此时两个线程相互等待,形成死锁。
避免死锁的方法有:
按顺序获取锁:确保所有线程按照相同的顺序获取锁,避免锁的交叉获取。
避免锁嵌套:尽量减少在一个同步块中获取另一个锁的情况,降低死锁发生的可能性。
设置锁超时:使用具有超时机制的锁获取方法,如ReentrantLock的tryLock(long timeout, TimeUnit unit)方法,当获取锁超时后,线程可以放弃等待,避免无限期等待。
使用资源分配图检测:在程序运行过程中,通过资源分配图来检测是否存在死锁,如果发现死锁,可以采取相应的措施进行处理,如终止某些线程或释放某些资源。
线程间通信
线程间如何进行通信?请列举几种方式。
答案:
使用wait()、notify()和notifyAll()方法:这三个方法是Object类的方法,在同步代码块或同步方法中使用。一个线程调用wait()方法后会释放锁并进入等待状态,其他线程可以调用notify()或notifyAll()方法唤醒等待的线程。notify()方法随机唤醒一个等待的线程,notifyAll()方法唤醒所有等待的线程。
使用BlockingQueue:BlockingQueue是一个阻塞队列,当队列满时,向队列中添加元素的线程会被阻塞;当队列为空时,从队列中获取元素的线程会被阻塞。通过这种方式实现线程间的通信和同步,例如ArrayBlockingQueue、LinkedBlockingQueue等。
使用CountDownLatch:CountDownLatch可以让一个或多个线程等待其他线程完成一组操作后再继续执行。通过countDown()方法减少计数器的值,当计数器的值为 0 时,等待的线程被唤醒继续执行。
使用CyclicBarrier:CyclicBarrier用于让一组线程互相等待,直到所有线程都到达某个屏障点,然后再一起继续执行。它可以重复使用,当所有线程都到达屏障后,屏障会被重置,可以再次使用。
解释一下wait()和sleep()方法的区别。
答案:
所属类:wait()方法是Object类的方法,而sleep()方法是Thread类的方法。
释放锁的行为:wait()方法会释放当前线程持有的对象锁,使得其他线程可以获取该锁并访问同步代码块或方法;sleep()方法不会释放锁,线程在睡眠期间仍然持有锁,其他线程无法访问被该线程锁住的资源。
使用场景:wait()方法通常用于线程间的通信和协作,例如一个线程等待另一个线程完成某个操作后再继续执行;sleep()方法主要用于让线程暂停一段时间,例如在循环中控制执行频率,或者在某些操作之间添加延迟。
唤醒方式:wait()方法需要被其他线程调用notify()或notifyAll()方法唤醒,或者等待指定的时间后自动唤醒;sleep()方法在指定的睡眠时间到达后自动唤醒。
并发容器与框架
请介绍一下ConcurrentHashMap的实现原理。
答案:ConcurrentHashMap是 Java 中用于在多线程环境下高效存储和访问数据的哈希表实现。它采用了分段锁(Segment)的技术,将整个哈希表分成多个段,每个段都有自己的锁。在 JDK 8 及以后的版本中,ConcurrentHashMap摒弃了分段锁的概念,采用了 CAS(Compare and Swap)操作和synchronized关键字来实现并发安全。
当进行插入、删除或查询操作时,ConcurrentHashMap首先根据键的哈希值确定要操作的桶(bucket)。对于插入操作,会使用 CAS 操作尝试将新元素插入到桶中,如果桶为空,则直接插入;如果桶不为空,则可能需要对桶中的元素进行遍历和更新。在遍历和更新过程中,会使用synchronized关键字对桶进行加锁,以确保同一时刻只有一个线程能够访问该桶。对于查询操作,由于ConcurrentHashMap的桶中的元素是通过链表或红黑树来存储的,所以查询操作可以在不加锁的情况下进行,通过 volatile 关键字保证了桶中元素的可见性,从而实现了高并发下的高效查询。
Java 中的BlockingQueue有哪些实现类?它们的特点是什么?
答案:
ArrayBlockingQueue:基于数组实现的有界阻塞队列,在创建时需要指定队列的容量。它按照先进先出(FIFO)的原则对元素进行排序,插入和删除操作在队列的两端进行,使用一把锁来保证并发安全。
LinkedBlockingQueue:基于链表实现的阻塞队列,可以指定队列的容量,也可以不指定,默认容量为Integer.MAX_VALUE。它同样按照 FIFO 原则对元素进行排序,插入和删除操作分别在链表的头和尾进行,使用两把锁(一把用于读操作,一把用于写操作)来提高并发性能。
PriorityBlockingQueue:基于优先级堆实现的无界阻塞队列,元素按照优先级进行排序。在插入元素时,会根据元素的优先级将其插入到合适的位置,取出元素时,会取出优先级最高的元素。它使用一把锁来保证并发安全。
DelayQueue:基于优先级队列实现的无界阻塞队列,队列中的元素必须实现Delayed接口,该接口定义了getDelay(TimeUnit unit)方法用于获取元素的延迟时间。只有当元素的延迟时间到期后,才能从队列中取出元素。它使用一把锁和一个条件变量来实现延迟队列的功能。
谈谈你对Executor框架的理解。它有哪些主要的组件?
答案:Executor框架是 Java 中用于管理和执行线程任务的框架,它提供了一种高效的方式来创建、执行和管理线程,将任务的提交与执行解耦,使得代码更加易于维护和扩展。
主要组件包括:
Executor接口:定义了一个execute(Runnable command)方法,用于执行给定的Runnable任务。
ExecutorService接口:继承自Executor接口,提供了更丰富的方法,如提交任务、关闭线程池等。它可以管理一组线程,并且可以通过不同的策略来分配任务给线程执行。
ThreadPoolExecutor类:ExecutorService接口的主要实现类,用于创建线程池。它可以根据不同的参数配置创建不同类型的线程池,如固定大小的线程池、可缓存的线程池等。通过线程池可以有效地复用线程,减少线程创建和销毁的开销,提高系统的性能和响应性。
ScheduledExecutorService接口:用于定时执行任务或周期性执行任务的接口,继承自ExecutorService接口。
ScheduledThreadPoolExecutor类:ScheduledExecutorService接口的实现类,用于创建定时线程池,可以按照指定的延迟时间或周期执行任务。
性能优化与实践
在多线程编程中,如何提高程序的性能?
答案:
合理使用线程池:线程池可以复用线程,减少线程创建和销毁的开销。根据任务的特点选择合适的线程池类型,如固定大小的线程池适用于任务数量相对稳定的情况,可缓存的线程池适用于任务数量波动较大的情况。
减少锁竞争:锁的竞争会导致线程阻塞和上下文切换,降低程序性能。可以通过优化锁的粒度,尽量缩小同步代码块的范围,或者使用无锁的数据结构和算法来避免锁竞争。
避免线程上下文切换:线程上下文切换会消耗一定的时间和资源。可以通过减少线程的数量、合理安排任务的执行顺序、避免不必要的阻塞等方式来减少线程上下文切换的发生。
使用无锁数据结构:在一些场景下,无锁数据结构可以提供更高的并发性能,如ConcurrentLinkedQueue、AtomicInteger等。这些数据结构通过使用 CAS 操作等技术来实现无锁并发访问,避免了锁的开销。
优化线程间通信:合理使用线程间通信机制,如BlockingQueue、CountDownLatch等,避免不必要的等待和唤醒操作,提高线程间的协作效率。
充分利用多核处理器:根据系统的处理器核心数量,合理分配线程数量,使得每个核心都能充分发挥作用,提高系统的并行度。
请描述一个你在实际项目中遇到的多线程问题,以及你是如何解决的。
答案:(以下是一个示例,你可以根据实际情况进行修改和补充)在一个电商项目中,有多个线程同时对商品库存进行更新操作。由于并发访问,出现了库存数据不一致的问题,有些订单扣除了库存但没有更新到数据库,而有些订单则更新了多次库存,导致库存数量不准确。
解决方法如下:
首先,分析问题的原因是多个线程对库存的并发更新没有进行有效的同步控制。
然后,使用ReentrantLock对库存更新操作进行加锁,确保在同一时刻只有一个线程能够更新库存。在更新库存的方法中,先获取锁,然后进行库存更新操作,最后释放锁。
同时,为了提高性能,对库存更新的逻辑进行了优化,将一些不必要的操作放在锁外面执行,只在锁内部执行关键的库存更新代码,减少锁的持有时间。
另外,添加了日志记录功能,对每次库存更新操作进行详细的日志记录,以便在出现问题时能够快速定位和排查。
通过以上措施,解决了库存数据不一致的问题,保证了多线程环境下库存更新的准确性和稳定性。