目录
1. 什么是线程和进程?
线程与进程有什么区别?
那什么是上下文切换?
进程间怎么通信?
什么是用户线程和守护线程?
2. 并行和并发的区别?
3. 创建线程的几种方式?
Runnable接口和Callable接口的区别?
run()方法和start()有什么区别?
4. Java线程状态和方法?
描述线程的生命周期?
一个线程两次调用start()方法会出现什么情况?
sleep()和wait()方法的区别是什么?
5. 并发编程的三要素是什么?
6. 什么是线程死锁?
怎么定位死锁?
7. Java并发包提供了哪些并发工具类?
并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?
8. 什么是线程池?
讲讲线程池的生命周期?
线程池有哪些类型?
线程池的拒绝策略?
线程池的执行流程?
Java并发类库提供的线程池有哪几种? 分别有什么特点?
Executor创建线程为什么不建议使用了?
9. AtomicInteger底层实现原理是什么?
什么是CAS?
10. 锁的分类有哪些?
synchronized和ReentrantLock有什么区别呢?
为什么Synchronized能实现线程同步?
锁升级过程?
1. 什么是线程和进程?
进程:是指一个在内存中运行的应用程序,常见的app都是一个个进程。进程具有自己独立的内存空间,一个进程可以有多个线程;
线程:是指进程中的一个执行任务的单元,负责执行当前进程中程序的执行,一个进程至少有一个线程,一个进程内的多个线程见可共享数据。
线程与进程有什么区别?
- 根本区别:进程是操作系统资源分配的单位,而线程是处理器任务调度和执行的基本单位;
- 资源开销:进程有独立的代码和数据空间(程序上下文),程序之间切换会有较大开销;线程可以看作轻量级进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程间切换开销较小;
- 包含关系:一个进程中至少有一个线程,基本都是有多个线程共同完成一个进行的运行;而线程是属于进行中的一部分;
- 内存分配:进程分配到独立的地址空间和资源,而线程是功效一个进行内的地址空间和资源的;
- 影响关系:进程崩溃不影响其他进程的执行,但线程崩溃会导致进程崩溃,所以进程比线程的健壮性好;
- 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但线程不可单独执行,必须依附在进程中,由应用程序提供多线程的控制。
那什么是上下文切换?
- 在多线程编程中,大多线程数量大于CPU核心个数,而一个CPU在某一时刻只能执行一个线程。为了让这些线程都能得到执行,CPU采用的策略是为每个线程分配一个时间片去轮流执行,当一个线程的时间片用完就会让这个线程重新处于就绪状态而把CPU让给其他线程使用,这个过程就叫做上下文切换。简单来说:任务从保存到再加载的过程就是一次上下文切换。
进程间怎么通信?
进程间的通信方式有很多,比如:管道、消息队列、共享内存、信号、嵌套字
- 管道:包含无名管道和命名管道,无名管道半双工,只能用于具有亲缘关系的进程间通信,可以看作一种特殊文件;命名管道可以允许无亲缘关系的进程间通信;
- 消息队列: 就是一个消息的链表,是一系列保存在内核中消息的列表。当一个进程需要通信的时候,只需要将数据写入这个消息列表当中,就可以正常退出干其他事情了,另一个进程需要数据的时候只需去读取数据就行了。消息队列独立于发送和接收进程,哪怕发送进程终止了,队列中数据也不会被删除;
- 信号:用于通知接收进程某个事件发生
- 内存共享:使多个进程访问同一块内存空间
- 嵌套字:socket,用于不同主机间直接的通信
什么是用户线程和守护线程?
- 用户线程:指的是运行在前台,执行具体任务的线程,如main所在县丞就是用户线程;
- 守护线程:指的是运行在后台,为其他用户线程服务的线程,如垃圾回收线程。特点是守护线程不影响JVM的退出。
2. 并行和并发的区别?
- 并发:多个任务在同一个CPU核上执行的时候,多个任务根据细分的时间片去交替执行,因为交替时间对人来说非常短暂,所以是一种感觉上的同时执行,但本质还是在某一时刻只执行一个线程;
- 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时执行,叫做并行。
多线程:就是指程序中包含多个执行流,就是在一个程序中可以同时运行多个不同的线程来执行不同的任务。
- 好处:提升了CPU的使用率:在多线程环境中,一个线程在等待时,其他线程可以运行(也就是说允许单个程序创建多个并行执行的线程来完成各自的任务),这样大大提高了程序的效率。
- 劣势:线程数量与内存占用成正比,线程越多消耗内存也越多;多线程需要协调和管理,所以需要CPU时间跟踪线程;线程之间对共享资源的访问会相互影响,也就是解决我们常说的线程共享资源问题(并发问题)。
3. 创建线程的几种方式?
- 实现Runnable接口
- 继承Thread类
- 实现Callable接口
- 使用Executors工具类创建线程池
Ps:有的文档也把匿名内部类和Lambda表达式的方式也分别算作创建线程的方式,主要回答这四种比较常规。
Runnable接口和Callable接口的区别?
- 返回值:Runnable的run()方法执行没有返回值,而Callable执行的call()方法可以返回执行结果;
- 异常处理:Runnable的run()方法不能抛出可被检查的异常,只能抛出非受检查的RuntimeException。而Callable接口的call()方法可以抛出任何类型的异常,包括受检查的异常。
- 兼容性:Callable接口是在Java 5中引入的新接口,而Runnable接口是在Java 1.0中就存在的。Callable接口提供了更多的灵活性和功能,但Runnable接口仍然是使用较多的接口之一,因为它的简单性和兼容性。
- 并发集合:Callable接口通常与ExecutorService和Future配合使用,以支持异步任务执行和获取结果。 Runnable接口通常与Thread类或者Executor框架一起使用,用于执行简单的线程任务。
run()方法和start()有什么区别?
每个线程都是通过其特定的Thread对象所对应的方法run()来完成其操作的,run()方法称为线程体,通过调用Thread类的start()方法来启动一个线程。
- start()方法用于启动线程,run()方法用于执行线程的运行代码;
- run()方法可以重复调用,而start()方法只能调用一次。
- start()方法来启动一个线程,真正的实现了多线程运行。调用satrt()方法无需等待run()方法体代码执行完毕,可以继续执行其他代码。此时线程处于就绪状态,并没有运行。然后调用run()方法来等待分配资源运行线程。
4. Java线程状态和方法?
Java线程状态有6种,分别是 NEW(新建状态)、RUNNABLE(就绪状态)、 BLOCKED(阻塞状态)、WAIT(等待状态)、TIME_WAIT(超时等待状态)、TERMINATED(终止状态):
- 新建状态(NEW):线程最new出来最初始的状态,还没调用start方法;
- 就绪状态(RUNNABLE): start()启动线程后,Java线程把操作系统中就绪和运行两种状态统一称为“运行中”;
- 阻塞状态(BLOCKED):表示线程进入等待状态,也就是线程因为某种原因放弃了CPU的使用权,阻塞也分为几种情况:
- 等待阻塞:运行的线程执行了Thread.sleep、wait、join等方法,JVM会把当前线程设置为等待状态,当sleep结束,join线程终止或者线程被唤醒后,该线程从等待状态进入阻塞状态,重新占用锁后进行线程恢复;
- 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其他线程锁占用了,那么JVM会把当前项城放入到锁池中;
- 其他阻塞:发出I/O请求,JVM会把当前线程设置为阻塞状态,当I/O处理完毕则线程恢复
- 等待状态(WAITIING):等待状态,没有超时时间(无限等待),要被其他线程或者有其他的中断操作 ,执行wait、join、LockSupport.park();
- 超时等待状态(TIME_WAIT):与等待不同的是它不是无限等待,超时后自动返回 ,执行sleep、带参数的wait等可以实现;
- 终止状态(TERMINATED):线程执行完毕的状态。
状态和方法对应图如下:
描述线程的生命周期?
线程的生命周期是指操作系统层面上的线程的五种状态,五大生命周期 分别为:新建(NEW),就绪(Runnable),运行(Running),阻塞(Blocked)(又分为 Blocked,waiting,time-waiting),死亡(Dead/TERMINATED)具体如下:
- 新建(NEW):新创建了一个线程对象。
- 可运行(RUNNABLE):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。
- 运行(RUNNING):可运行状态(RUNNABLE)的线程获得了CPU 时间片,执行程序代码。
- 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。
- 死亡(DEAD):线程run()、main() 方法执行结束或因异常退出了run()方法,则该线程结束生命周期。
一个线程两次调用start()方法会出现什么情况?
Java 的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。
sleep()和wait()方法的区别是什么?
虽然两个方法都有让线程暂停的作用,但是两个还是发挥不同作用的:
- wait()是Object类的方法,sleep()是Thread类的方法;
- wait()释放锁,让线程进入等待状态,sleep()不释放锁,让线程进入阻塞状态;
- wait()方法后线程不会自动恢复执行,需要手动调用notify()/notifyAll()方法唤醒,sleep()在睡眠固定时间后会走动苏醒;
- wait()常用于线程间交互/通信,sleep通常被用于暂停等待。
5. 并发编程的三要素是什么?
- 原子性:原子是指一个不可再分割的颗粒,原子性值得是一个或者多个操作要么全部成功要么全部失败;
- 可见性:一个线程对贡献变量的修改,另一个线程能够立刻看到(volatile,synchronized);
- 有序性:程序执行代码的顺序要按照代码的先后顺序执行。
可能出现线程安全问题的原因:
- 线程切换带来原子性问题
- 缓存导致可见性问题
- 编译优化带来的有序性问题
解决办法:
- JDK Atomic开头的原子类,synchronized、lock等可解决原子性问题
- synchronized、lock、volatile可解决原子性问题
- Happens-before规则可以解决有序性问题
6. 什么是线程死锁?
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。死锁示意图:
形成死锁的四个必要条件:
- 互斥条件:线程申请的资源在一段时间中只能被一个线程使用
- 请求与等待条件:线程已经拥有了一个资源,但是又申请新的资源,拥有的资源保持不变 。
- 不可剥夺条件:在一个线程没有用完,主动释放资源的时候,不能被抢占。
- 循环等待条件:多个线程之间存在资源循环链。
处理死锁的方法:
- 预防死锁:破坏死锁产生的四个条件之一,注意,互斥条件不能破坏。
- 避免死锁:合理的分配资源。
- 检查死锁:利用专门的死锁机构检查死锁的发生,然后采取相应的方法。
- 解除死锁:发生死锁时候,采取合理的方法解决死锁,一般是强行剥夺资源。
怎么定位死锁?
最常见的方式就是利用jstack等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。
7. Java并发包提供了哪些并发工具类?
- 提供了比 synchronized 更加高级的各种同步结构,包括 CountDownLatch、CyclicBarrier、Semaphore 等,可以实现更加丰富的多线程操作,比如利用 Semaphore 作为资源控制器,限制同时进行工作的线程数量。
- 各种线程安全的容器,比如最常见的 ConcurrentHashMap、有序的 ConcurrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数组 CopyOnWriteArrayList 等。
- 各种并发队列实现,如各种 BlockingQueue 实现,比较典型的 ArrayBlockingQueue、 SynchronousQueue 或针对特定场景的 PriorityBlockingQueue 等。
- 强大的 Executor 框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。
Ps:java.util.concurrent 包提供的容器(Queue、List、Set)、Map,从命名上可以大概区分为 Concurrent*、CopyOnWrite和 Blocking等三类,同样是线程安全容器,可以简单认为:
- Concurrent 类型没有类似 CopyOnWrite 之类容器相对较重的修改开销。
- 但是,凡事都是有代价的,Concurrent 往往提供了较低的遍历一致性。
- 你可以这样理解所谓的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历。
- 与弱一致性对应的,就是我介绍过的同步容器常见的行为“fail-fast”,也就是检测到容器在遍历过程中发生了修改,则抛出 ConcurrentModificationException,不再继续遍历。
- 弱一致性的另外一个体现是,size 等操作准确性是有限的,未必是 100% 准确。与此同时,读取的性能具有一定的不确定性。
并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?
LinkedBlockingQueue 和 ConcurrentLinkedQueue 是 Java 高并发场景中最常使用的队列。尽管这两个队列经常被用作并发场景的数据结构,但它们之间仍有细微的特征和行为差异。LinkedBlockingQueue 是一个 “可选且有界” 的阻塞队列实现,ConcurrentLinkedQueue 是一个无边界、线程安全且无阻塞的队列。
相似之处:
- 都实现 Queue 接口
- 它们都使用 linked nodes 存储节点
- 都适用于并发访问场景
不同之处:
特性
LinkedBlockingQueue
ConcurrentLinkedQueue
阻塞性
阻塞队列,并实现
blocking queue
接口
非阻塞队列,不实现
blocking queue
接口
队列大小
可选的有界队列,这意味着可以在创建期间定义队列大小
无边界队列,并且没有在创建期间指定队列大小的规定
锁特性
基于锁的队列
无锁队列
算法
锁的实现基于 “双锁队列(two lock queue)” 算法
依赖于
Michael&Scott算法来实现无阻塞、无锁队列
实现
在双锁队列算法机制中,
LinkedBlockingQueue使用两种不同的锁,putLock和takeLock。put/take
操作使用第一个锁类型,take/poll操作使用另一个锁类型
使用CAS(Compare And Swap)进行操作
阻塞行为
当队列为空时,它会阻塞访问线程
当队列为空时返回 null,它不会阻塞访问线程
8. 什么是线程池?
线程池就是管理线程的一个容器,有任务需要处理时,会相继判断核心线程数是否还有空闲、线程池中的任务队列是否已满、是否超过线程池大小,然后调用或创建线程或者排队,线程执行完任务后并不会立即被销毁,而是仍然在线程池中等待下一个任务,如果超过存活时间还没有新的任务就会被销毁,通过这样复用线程从而降低开销。
使用线程池的优点:
- 提升线程池中线程的使用率,减少对象的创建、销毁。
- 线程池的伸缩性对性能有较大的影响,使用线程池可以控制线程数,有效的提升服务器的使用资源,避免由于资源不足而发生宕机等问题。
讲讲线程池的生命周期?
- RUNNING :接收新的任务,并且可执行队列里的任务
- SHUTDOWN :shutdown()方法将线程池状态转换为SHUTDOWN,停止接收新任务,但可执行队列里的任务
- STOP :shutdownNow()方法将线程池状态转换为STOP,同时中断所有线程,停止接收新任务,不执行队列里的任务,中断正在执行的任务
- TIDYING :所有任务都已终止,线程数为0,线程池变为TIDYING状态,会执行钩子函数terminated(),钩子方法是指使用一个抽象类实现接口,一个抽象类实现这个接口,需要的方法设置为abstract,其它设置为空方法
- TERMINATED :执行完terminated()钩子方法,线程池已终止,变为TERMINATED状态
线程池有哪些类型?
- FixedThreadPool:固定线程数的线程池,核心线程数和最大线程数一样。特点是当线程达到核心线程数后,如果任务队列满了,也不会创建额外的非核心线程去执行任务,而是执行拒绝策略。
- CachedThreadPool:缓存线程池,特点是线程数几乎是可以无限增加的(最大值是Integer.MAX_VALUE,基本不会达到),当线程闲置时还可以进行回收,而且它采用的存储任务的队列是SynchronousQueue队列,队列容量是0,实际不存储任务,只负责对任务的中转和传递,所以来一个任务线程池就看是否有空闲的线程,有的话就用空闲的线程去执行任务,否则就创建一个线程去执行,效率比较高。
- ScheduledThreadPool:支持定时或者周期性执行任务
- SingleThreadExecutor:线程池中只有一个线程去执行任务,如果执行任务过程中发生了异常,则线程池会创建一个新线程来执行后续任务,这个线程因为只有一个线程,所以可以保证任务执行的有序性。
- SingleThreadScheduleExecutor:和ScheduledThreadPool很相似,只不过它的内部也只有一个线程,他只是将核心线程数设置为了1,如果执行期间发生异常,同样会创建一个新线程去执行任务。
- ForkJoinPool:支持将一个任务拆分成多个“小任务”并行计算,这个线程池是在jdk1.7之后加入的,它主要用于实现“分而治之”的算法,特别是分治之后递归调用的函数。
线程池的拒绝策略?
当任务队列和线程池都满了时所采取的应对策略:
- 默认是AbordPolicy,表示无法处理新任务,并抛出RejectedExecutionException异常;
- CallerRunsPolicy:用调用者所在的线程处理任务。此策略提供简单的反馈机制,能够减缓新任务的提交速度;
- DiscardPolicy:不能执行任务,并将任务删除;
- DiscardOldestPolicy:丢弃队列最近的任务,并执行当前的任务。
线程池的执行流程?
Java并发类库提供的线程池有哪几种? 分别有什么特点?
通常开发者都是利用 Executors 提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的 ExecutorService 类型或者不同的初始参数。Executors 目前提供了 5 种不同的线程池创建配置:
- newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列。
- newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads。
- newSingleThreadExecutor(),它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。
- newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize),创建的是个 ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。
- newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。
Executor创建线程为什么不建议使用了?
- 缺乏对线程池的精细控制:Executors 提供的方法通常创建一些简单的线程池,如固定大小的线程池、单线程线程池等。然而,这些线程池的配置通常是有限制的,难以进行进一步的定制和优化。
- 可能引发内存泄漏:一些 Executors 创建的线程池,特别是 FixedThreadPool 和 SingleThreadExecutor,使用无界队列来存储等待执行的任务。这意味着如果任务提交速度远远快于任务执行速度,队列中可能会积累大量未执行的任务,可能导致内存泄漏。
- 不易处理异常:Executors 创建的线程池默认使用一种默认的异常处理策略,通常只会将异常打印到标准输出或记录到日志中,但不会提供更多的控制。这可能导致异常被忽略或无法及时处理。
- 不支持线程池的动态调整:某些线程池应该支持动态调整线程数量以应对不同的负载情况。Executors 创建的线程池通常是固定大小的,不容易进行动态调整。
- 可能导致不合理的线程数目:一些 Executors 方法创建的线程池默认将线程数目设置为非常大的值,这可能导致系统资源的浪费和性能下降。
9. AtomicInteger底层实现原理是什么?
AtomicIntger 是对 int 类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于 CAS(compare-and-swap)技术。AtomicInteger是java.util.concurrent.atomic 包下的一个原子类,该包下还有AtomicBoolean, AtomicLong,AtomicLongArray, AtomicReference等原子类,主要用于在高并发环境下,保证线程安全。
什么是CAS?
所谓 CAS,表征的是一系列操作的集合,获取当前数值,进行一些运算,利用 CAS 指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。CAS 是 Java 并发中所谓 lock-free 机制的基础。执行流程如图:
10. 锁的分类有哪些?
常见描述种的锁以及其解释:
- 乐观锁:乐观锁认为一个线程去拿数据的时候不会有其他线程对数据进行更改,所以不会上锁。实现:CAS机制、版本号机制。以Atomic开头的包装类,例如AtomicBoolean,AtomicInteger,AtomicLong。适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
- 悲观锁:悲观锁认为一个线程去拿数据时一定会有其他线程对数据进行更改。所以一个线程在拿数据的时候都会顺便加锁,这样别的线程此时想拿这个数据就会阻塞。实现:synchronized关键字和Lock的实现类加锁。(Synchronized关键字会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。 尽管Java1.6为Synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。)
- 自旋锁:自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
- 公平锁:多个线程相互竞争时要排队,等待线程按照申请锁的顺序来获取锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
- 非公平锁:多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待,即先插队再排队。有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
- 可重入锁:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。优点:避免死锁
- 分段锁 :设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。 实现:CurrentHashMap底层就用了分段锁。
- 独享锁:该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。
- 共享锁:该锁可以被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
- 互斥锁:具体实现就是synchronized、ReentrantLock和JUC中Lock的实现类
- 读写锁:ReentrantReadWriteLock中的读锁ReadLock是共享锁,写锁WriteLock是独享锁。
synchronized和ReentrantLock有什么区别呢?
- 原始构成:synchronized是java关键字属于JVM层面。 monitorenter(底层通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象只有在同步块或方法中才能调用wait/notify等方法)。ReentrantLock是具体类(java.util.concurrent.locks.Lock)是API层面。
- 使用方法:synchronized不需要手动释放锁,代码执行完系统会自动让线程释放对锁的占用 ReentrantLock则需要手动去释放锁,若没有主动释放优肯出现死锁,也就是lock()和unlock()方法需要配合try/finally语句快来使用
- 等待是否可以中断:synchronized不可中断,除非抛出异常或者正常运行完成。ReentrantLock可中断:
- 设置超时时间 tryLock(long timeout,TimeUnit unit)
- lockInterruptibly()房代码块中调用interrupt()方法可中断
- 枷锁是否公平:synchronized是非公平锁,ReentrantLock 两者都可,默认是非公平锁(构造方法传入boolean值,true是公平锁,false是非公平锁)
- 锁绑定多个条件Condition:ReentrantLock 用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized要么随机唤醒一个要么全部唤醒
为什么Synchronized能实现线程同步?
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的。synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。 如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
Ps:
- Java对象头 :以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
- Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象无关的数据,所以Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存尽量存储更多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。
- Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Monitor:可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。 Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
锁升级过程?
锁升级过程是由无锁,偏向锁,轻量级锁,到重量级锁的过程。多个线程在争抢synchronized 锁时,在某些情况下,会由无锁状态一步步升级为最终的重量级锁状态。整个升级过程大致包括如下几个步骤:
- 线程在竞争 synchronized 锁时,JVM 首先会检测锁对象的 Mark word 中偏向锁锁标记位是否为 1,锁标记位是否为 01,如果两个条件都满足,则当前锁处于可偏向的状态。
- 争抢 synchronized 锁的线程检查锁对象的 Mark Word 中存储的线程 ID 是否是自己的线程 ID ,如果是自己的线程 ID,则表示处于偏向锁状态。当前线程可以,直接进入方法或者代码块执行逻辑。
- 如果锁对象的 Mark word 中存储的不是当前线程的 ID,则当前线程会通过 CAS 自旋的方式竞争锁资源。如果成功抢占到锁,则将 Mark Word 中存储的线程 ID 修改为自己的线程 ID ,将偏向锁标记设置为 1,锁标志位设置为 01,当前锁处于偏向锁状态。
- 如果当前线程通过 CAS 自旋操作竞争锁失败,则说明此时有其他线程也在争抢锁资源。此时会撤销偏向锁,触发升级为轻量级锁的操作。
- 当前线程会根据锁对象的 Mark word 中存储的线程 ID 通知对应的线程暂停,对应的线程会将 Mark Word 的内容置空。
- 当前线程与上次获取到锁的线程都会把锁对象的 HashCode 等信息复制到自己的 Displaced Mark Word中,随后两个线程都会执行 CAS 自旋操作,尝试把锁对象的 Mark Word 中的内容修改为指向自己的 Displaced Mark Word 空间来竞争锁。
- 竞争锁成功的线程获取到锁,执行方法或代码块中的逻辑。同时,竞争锁成功的线程会将锁对象的 Mark Word 中的锁标志位设置为 00,此时进入轻量级锁状态。
- 竞争失败的线程会继续使用 CAS 自旋的方式尝试竞争锁,如果自旋成功竞争到锁,则当前锁仍然处于轻量级锁状态。
- 如果线程的 CAS 自旋操作达到一定次数仍未获取到锁,则轻量级锁会膨胀为重量级锁,此时会将镇对锁的 Mark Word 中的锁标志位设置为 10,进人重量级锁状态。