Appearance
Java 多线程
多线程是什么
得分点 线程和进程的关系、为什么使用多线程 标准回答 线程是操作系统调度的最小单元,它可以让一个进程并发地处理多个任务,也叫轻量级进程。所以,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈、局部变量,并且能够共享进程内的资源。由于共享资源,处理器便可以在这些线程之间快速切换,从而让使用者感觉这些线程在同时执行。 总的来说,操作系统可以同时执行多个任务,每个任务就是一个进程。进程可以同时执行多个任务,每个任务就是一个线程。一个程序运行之后至少有一个进程,而一个进程可以包含多个线程,但至少要包含一个线程。 使用多线会给开发人员带来显著的好处,而使用多线程的原因主要有以下几点:
- 更多的 CPU 核心 现代计算机处理器性能的提升方式,已经从追求更高的主频向追求更多的核心发展,所以处理器的核心数量会越来越多,充分地利用处理器的核心则会显著地提高程序的性能。而程序使用多线程技术,就可以将计算逻辑分配到多个处理器核心上,显著减少程序的处理时间,从而随着更多处理器核心的加入而变得更有效率。
- 更快的响应时间 我们经常要针对复杂的业务编写出复杂的代码,如果使用多线程技术,就可以将数据一致性不强的操作派发给其他线程处理(也可以是消息队列),如上传图片、发送邮件、生成订单等。这样响应用户请求的线程就能够尽快地完成处理,大大地缩短了响应时间,从而提升了用户体验。
- 更好的编程模型 Java 为多线程编程提供了良好且一致的编程模型,使开发人员能够更加专注于问题的解决,开发者只需为此问题建立合适的业务模型,而无需绞尽脑汁地考虑如何实现多线程。一旦开发人员建立好了业务模型,稍作修改就可以将其方便地映射到 Java 提供的多线程编程模型上。
请你说说线程和进程的区别
得分点 地址空间、开销、并发性、内存 标准回答 进程和线程的主要差别在于它们是不同的操作系统资源管理方式。
- 进程有独立的地址空间,线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间;
- 进程和线程切换时,需要切换进程和线程的上下文,进程的上下文切换时间开销远远大于线程上下文切换时间,耗费资源较大,效率要差一些;
- 进程的并发性较低,线程的并发性较高;
- 每个独立的进程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制;
- 系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了 CPU 外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源;
- 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
怎么保证线程安全
得分点 原子类、volatile、锁
Java 保证线程安全的方式有很多,其中较为常用的有三种,按照资源占用情况由轻到重排列,这三种保证线程安全的方式分别是原子类、volatile、锁。
JDK 从 1.5 开始提供了 java.util.concurrent.atomic 包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。在 atomic 包里一共提供了 17 个类,按功能可以归纳为 4 种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。无论原子更新哪种类型,都要遵循“比较和替换”规则,即比较要更新的值是否等于期望值,如果是则更新,如果不是则失败。
volatile 是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”,从而可以保证单个变量读写时的线程安全。可见性问题是由处理器核心的缓存导致的,每个核心均有各自的缓存,而这些缓存均要与内存进行同步。volatile 具有如下的内存语义:当写一个 volatile 变量时,该线程本地内存中的共享变量的值会被立刻刷新到主内存;当读一个 volatile 变量时,该线程本地内存会被置为无效,迫使线程直接从主内存中读取共享变量。 原子类和 volatile 只能保证单个共享变量的线程安全,锁则可以保证临界区内的多个共享变量的线程安全,Java 中加锁的方式有两种,分别是 synchronized 关键字和 Lock 接口。
synchronized 是比较早期的 API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让它支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。因此,JDK 的开发团队在 1.5 新增了 Lock 接口,并通过 Lock 支持了上述的功能,即:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)。
加分回答:实现线程安全的方式有很多,除了上述三种方式之外,还有如下几种方式:
- 无状态设计 线程安全问题是由多线程并发修改共享变量引起的,如果在并发环境中没有设计共享变量,则自然就不会出现线程安全问题了。这种代码实现可以称作“无状态实现”,所谓状态就是指共享变量。
- 不可变设计 如果在并发环境中不得不设计共享变量,则应该优先考虑共享变量是否为只读的,如果是只读场景就可以将共享变量设计为不可变的,这样自然也不会出现线程安全问题了。具体来说,就是在变量前加 final 修饰符,使其不可被修改,如果变量是引用类型,则将其设计为不可变类型(参考 String 类)。
- 并发工具 java.util.concurrent 包提供了几个有用的并发工具类,一样可以保证线程安全:
- Semaphore:就是信号量,可以控制同时访问特定资源的线程数量。
- CountDownLatch:允许一个或多个线程等待其他线程完成操作。
- CyclicBarrier:让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。
- 本地存储 我们也可以考虑使用 ThreadLocal 存储变量,ThreadLocal 可以很方便地为每一个线程单独存一份数据,也就是将需要并发访问的资源复制成多份。这样一来,就可以避免多线程访问共享变量了,它们访问的是自己独占的资源,它从根本上隔离了多个线程之间的数据共享。
死锁定义及发生的条件?
得分点 争夺共享资源、相互等待、互斥条件、请求和保持条件、不剥夺条件、环路等待条件
死锁 两个或两个以上的进程在执行过程中,因争夺共享资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。这些永远在互相等待的进程称为死锁进程。
产生死锁的必要条件 虽然进程在运行过程中,可能发生死锁,但死锁的发生也必须具备一定的条件,死锁的发生必须具备以下四个必要条件:
- 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放;
- 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放;
- 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放;
- 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合 {P0,P1,P2,···,Pn} 中的 P0 正在等待一个 P1 占用的资源;P1 正在等待 P2 占用的资源,……,Pn 正在等待已被 P0 占用的资源。
请你说说线程和协程的区别?
- 线程是操作系统的资源,线程的创建、切换、停止等都非常消耗资源,而创建协程不需要调用操作系统的功能,编程语言自身就能完成,所以协程也被称为用户态线程,协程比线程轻量很多;
- 线程在多核环境下是能做到真正意义上的并行,而协程是为并发而产生的;
- 一个具有多个线程的程序可以同时运行几个线程,而协同程序却需要彼此协作的运行;
- 线程进程都是同步机制,而协程则是异步;
- 线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力;
- 操作系统对于线程开辟数量限制在千的级别,而协程可以达到上万的级别。
说说你了解的线程同步方式?
得分点 synchronized、Lock
Java 主要通过加锁的方式实现线程同步,而锁有两类,分别是 synchronized 和 Lock。 synchronized 可以加在三个不同的位置,对应三种不同的使用方式,这三种方式的区别是锁对象不同:
- 加在普通方法上,则锁是当前的实例(this)
- 加在静态方法上,则锁是当前类的 Class 对象
- 加在代码块上,则需要在关键字后面的小括号里,显式指定一个对象作为锁对象。
不同的锁对象,意味着不同的锁粒度,所以我们不应该无脑地将它加在方法前了事,尽管通常这可以解决问题。而是应该根据要锁定的范围,准确的选择锁对象,从而准确地确定锁的粒度,降低锁带来的性能开销。
synchronized 是比较早期的 API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让 synchronized 支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。
因此,JDK 的开发团队在 1.5 引入了 Lock 接口,并通过 Lock 支持了上述的功能。Lock 支持的功能包括:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)。
加分回答
- synchronized 采用“CAS+Mark Word”实现,为了性能的考虑,并通过锁升级机制降低锁的开销。在并发环境中,synchronized 会随着多线程竞争的加剧,按照如下步骤逐步升级:无锁、偏向锁、轻量级锁、重量级锁。
- Lock 则采用“CAS+volatile”实现,其实现的核心是 AQS。AQS 是线程同步器,是一个线程同步的基础框架,它基于模板方法模式。在具体的 Lock 实例中,锁的实现是通过继承 AQS 来实现的,并且可以根据锁的使用场景,派生出公平锁、不公平锁、读锁、写锁等具体的实现。
Java 实现多线程有哪几种方式
- 继承 Thread 类,重写 run 方法
- 实现 Runnable 接口,重写 run 方法,实现 Runnable 接口的实现类的实例对象作为 Thread 构造函数的 target
- 通过 Callable 和 FutureTask 创建线程
- 通过线程池创建线程
前面两种可以归结为一类:无返回值,原因很简单,通过重写 run 方法,run 方式的返回值是 void,所以没有办法返回结果
后面两种可以归结成一类:有返回值,通过 Callable 接口,就要实现 call 方法,这个方法的返回值是 Object,所以返回的结果可以放在 Object 对象中