Java笔记 | 多线程并发
多线程的内容很多,单开一篇。
- 进程与线程
--进程是资源分配的最小单位;线程是CPU调度的最小单位。
--进程在其执行的过程中可以产生多个线程;多个线程共享进程的堆和方法区内存资源。
--进程拥有独立的地址空间,进程崩溃不会对其他进程造成影响;每个线程都有自己的程序计数器、虚拟机栈和本地方法栈,线程崩溃会导致整个进程崩溃。
- 线程间的通信方式
-- 等待通知机制
wait()、notify()、join()、interrupted()
-- 并发工具
synchronized、lock、CountDownLatch、CyclicBarrier、Semaphore
- 创建多线程
1.继承Thread类,重写run()方法。
--启动用start()方法,每个线程只能启动一次。
2.实现Runnable接口,重写run()方法。
--需要先转换为Thread对象。
// Runnable对象 --> Thread对象 --> start
PrintRunnable pr = new PrintRunnable();
Thread t1 = new Thread(pr);
t1.start();
3.实现Callable接口,重写call()方法。
--实现Callable接口的call()方法具有返回值且可以抛出异常,而Runnable不可以。
--使用FutureTask类包装Callable对象,调用get()方法获得子线程执行结束后的返回值。
--使用Thread类包装FutureTask对象创建线程。
一般采用实现Runnable接口的创建方式。
--Thread类定义了多种方法可以被子类使用或重写,但只有run()方法实现了线程的主要功能,必须被重写。
--如果没有必要重写Thread类中的其他方法,那么应该通过实现Runnable接口来创建线程。
如果一个类同时继承Thread类并实现Runnable接口,且没有实现run()方法,也可以正常编译运行,因为继承了Thread类的run()方法。
- Java线程的状态
初始状态 (NEW)
运行状态 (RUNNABLE)
阻塞状态 (BLOCKED)
等待状态 (WAITING)
超时等待状态 (TIMED_WAITING)
终止状态 (TERMINATED)
- 终止线程的方式
1.正常运行结束:程序运行结束,线程自动结束。
2.使用退出标志:有些线程是伺服线程。它们需要长时间的运行,只有在外部某些条件满足的情况下,才能关闭这些线程。
3.Interrupt 方法结束线程:
--线程处于阻塞状态:当调用线程的 interrupt()方法时,会抛出 InterruptException 异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后 break 跳出循环状态。
--线程未处于阻塞状态:使用 isInterrupted() 判断线程的中断标志来退出循环。
4.stop 方法终止线程:(线程不安全)thread.stop()调用之后,创建子线程的线程就会抛出 ThreadDeatherror 的错误,并且会释放子线程所持有的所有锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。
- Java线程的生命周期
- run() 与 start()
系统调用start()启动线程,该线程处于就绪状态,可以被JVM调度执行。JVM调用run()方法完成实际操作,run()方法结束后线程终止。
如果直接调用run()方法会被当作调用普通的方法,无法实现多线程,程序中只有主线程。
只有通过调用start()方法才能实现多线程,start()方法能够异步调用run()方法。
- sleep() 与 wait()
1.原理不同
--sleep()方法是Thread类的静态方法,是线程用来控制自身流程的,会使该线程暂停执行一段时间,把执行机会让给其他线程,等时间结束后该线程自动“苏醒”。
--wait()方法是Object类的方法,用于线程间的通信,会使当前拥有该对象锁的进程等待,直到其他线程调用notify()或notifyAll()才“苏醒”,也可以指定时间自动“苏醒”。
2.对锁的处理机制不同
--sleep()只会让出CPU。
--wait()不仅让出CPU,还会释放已占有的同步资源锁。
3.使用区域不同
--sleep()可以在任何地方使用。
--wait()只能在synchronized方法或块中使用。
- join()
作用:让调用该方法的线程在执行完run()方法后,再执行join方法后面的代码,也就是将两个线程合并以实现同步功能。
使用:通过线程的join方法来等待该线程的结束,最多等待2s。
- synchronized
特性:原子性、可见性、有序性
作用:主要就是实现原子性操作和解决共享变量的内存可见性问题。
使用:
--修饰实例方法:对实例对象加锁
--修饰静态方法:对类的Class对象加锁
--修饰代码块:对代码块内的对象加锁
原理:
--对象模型:对象头、实例数据、对齐填充。
实例数据:用于存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
对齐填充:虚拟机要求对象起始地址必须是8字节的整数倍,仅用于字节对齐。
对象头:实现 synchronized 的锁对象的基础。一般来说,synchronized 使用的锁对象是存储在对象头里面的,jvm 中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要由 Mark Word 和 Class Metadata Address 组成。
Mark Word:存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息。
Class Metadata Address:类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例。
在 32位 JVM 中,Mark Word 的默认存储结构(无锁结构)如下:
25 bit:对象 HashCode
4 bit:对象分代年龄
1 bit:是否是偏向锁,默认为0
2 bit:锁标志位,默认为01
synchronized 是 java 提供的原子性内置锁在JVM级别实现,会在生成的字节码中加上 monitor enter 和 monitor exit,任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。monitor是JVM的一个同步工具,synchronized还通过内存指令屏障来保证共享变量的可见性。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。
执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
优化:
--偏向锁:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。但是对于锁竞争比较激烈的场合,偏向锁就失效了,这种场合下不应该使用偏向锁。偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
--轻量级锁:若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
--自旋锁:轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。
--消除锁:消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
- synchronized 与 volatile
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别。
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
- synchronized 与 Lock
1.用法
--synchronized既可以加在方法上,也可以加在代码块中;Lock通过代码实现,有更精确的线程语义,但需要手动释放,还提供了多样化的同步,比如公平锁、有时间限制的同步、可以被中断的同步。
--synchronized是托管给JVM执行的;Lock是通过代码实现的。
2.性能
ReentrantLock是Lock接口的实现类,它拥有和synchronized相同的并发性和内存语义,还多了中断锁、定时锁等。
--在资源竞争不是很激烈的情况下,synchronized的性能稍微优于ReentrantLock;在资源竞争很激烈的情况下,ReentrantLock性能保持不变,但synchronized性能下降很快。
3.锁机制
--synchronized获得和释放都是在块结构中,获取多个锁时以相反的顺序释放,不会因为出现异常导致锁没有释放从而引发死锁。
--Lock需要在finally块中手动释放,否则会引起死锁。
- synchronized 与 ReentrantLock
相同点
--都使用加锁的方式进行同步。
--都是阻塞式同步(互斥锁),也就是说当一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须在同步块外等待。
--都是可重入的锁,可以由一个线程多次获取。
不同点
--synchronized是Java语言中的关键字,是在原生语法层面上的互斥,需要由JVM来实现,也就是阻塞的线程由JVM调度器来进行唤醒。ReentrantLock需要使用lock()和unlock()显式进行锁的获取和释放。所以synchronized使用比较方便,但是ReetrantLock使用更加灵活。
--synchronized 不可以响应中断,ReentrantLock 可响应中断、可轮回。
--synchronized实现的原理是在它修饰的地方,经过编译之后会生成两条字节码指令,指令指定一个引用类型来尝试获取对象的锁,锁的互斥量存放在对象头之中。ReentrantLock内部是使用AQS队列同步器来实现的,使用一个int类型的成员变量来表示同步状态,使用CAS操作来获取锁。(AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。AQS是自旋锁。实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物)
AQS原理:AQS内部维护一个state状态位,尝试加锁的时候通过CAS(CompareAndSwap)修改值,如果成功设置为1,并且把当前线程ID赋值,则代表加锁成功,一旦获取到锁,其他的线程将会被阻塞进入阻塞队列自旋,获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把state重新置为0,同时当前线程ID置为空。
--synchronized获取锁的方式比较单一,而且是非公平锁。ReentrantLock可以通过维护一个FIFO队列来实现公平锁的获取,而且支持非阻塞获取锁、响应中断地获取锁、超时获取锁等操作。
--ReentrantLock可以同时绑定多个Condition条件对象。
性能
--在Synchronized优化以前,synchronized 的性能是比 ReenTrantLock 差很多的,但是自从 synchronized 引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用 synchronized。
- volatile
特性:
--变量可见性。保证此变量对所有线程的可见性,指当一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的。
--禁止重排序。禁止JVM进行指令重排序优化。使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题。
实现原理:
--有volatile修饰的变量,赋值后多执行了一个"lock add $0x0,(%esp)"操作,这个操作相当于一个内存屏障,重排序时不能把后面的指令重排序到内存屏障之前的位置。这条指令的另外一个作用是,将本处理器的缓存写入内存,该写入动作也会引起别的处理器或者别的内核的无效化其缓存,可以让volatile变量的修改对其他处理器立即可见。
性能:
--volatile变量读操作的性能消耗与普通变量几乎没有什么区别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即使如此,大多数场景下volatile的总开销仍比锁来得低。
适用场景(必须同时满足下面两个条件才能保证在并发环境的线程安全):
--对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值(boolean flag = true)
--不同的 volatile 变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile。
- 多线程同步的实现方法
1.synchronized关键字
每个对象都有一个对象锁,该锁表明对象在任何时候只允许被一个线程所拥有,当一个线程调用对象的一段synchronized代码时,需要先获取这个锁,然后去执行相应的代码,执行结束后,释放锁。
--synchronized方法:当方法体的规模非常大时会大大影响效率。
--synchronized块:既可以把任意的代码段声明为synchronized,也可以指定上锁的对象,有非常高的灵活性。
2. wait() 方法与 notify() 方法
在synchronized代码被执行期间,线程可以调用对象的 wait() 方法,释放对象锁,进入等待状态,并且可以调用 notify() 方法或 notifyAll() 方法通知正在等待的其他线程。notify() 仅唤醒一个线程并允许它去获得锁,notifyAll() 唤醒所有等待该对象的线程并允许它们去竞争获得锁。
3.Lock
--lock():以阻塞的方式获取锁。如果获取到锁,则立即返回;如果别的线程持有锁,则当前线程等待,直到获取锁后返回。
--tryLock():以非阻塞的方式获取锁。如果获取到锁,则返回true;如果别的线程持有锁,则返回false。
--tryLock(long timeout, TimeUnit unit):如果获取到锁,则返回true;否则等待参数给定的时间单元,在等待的过程中如果获取到锁,则返回true,如果等待超时,则返回false。
--lockInterruptibly():如果获取到锁,立即返回;如果没有获取锁,当前线程处于休眠状态,直到获得锁,或者当前线程被别的线程中断。它与lock()的最大区别在于如果lock()方法获取不到锁,会一直处于阻塞状态,且会忽略interrupt()方法。
- 为什么要使用线程池?
1.降低资源消耗。重复利用已创建的线程可以降低创建和销毁造成的资源消耗。
2.提高响应速度。任务不需要等待线程创建就可以执行。
3.提高线程的可管理性。无限制地创建线程会消耗系统资源,降低系统稳定性。
- 线程池的创建
newSingleThreadExecutor:创建一个单线程的线程池。如果该线程因为异常而结束,那么会有一个新的线程来替代它。
newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大值,一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(默认 60 秒不执行任务)的线程。当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。
newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
- 线程池的核心参数
int corePoolSize:常驻线程数,即空闲时仍保留在线程池中的线程数。
int maximumPoolSize:线程池中允许的最大线程数。
int keepAliveTime:存活时间。多余闲置线程能存活的最大时间,为0会被立即回收。
TimeUnit unit:keepAliveTime的单位。
BlockingQueue<Runnable> workQueue:被提交尚未被执行的任务阻塞队列。
ThreadFactory threadFactory:创建线程的工厂。
RejectedExecutionHandler handler:饱和拒绝策略,即当队列满了并且线程个数达到maximunPoolSize后采取的策略。
拒绝策略:
AbortPolicy(丢弃任务并抛出异常)
CallerRunsPolicy(由调用者线程处理任务)
DiscardOldestPolicy(丢弃最旧任务,添加并执行当前任务)
DiscardPolicy(直接丢弃任务不抛出异常)
- 如何设置初始化线程池的大小
--CPU密集型任务。这类任务通常CPU利用率很高,无阻塞,应配置尽可能少的线程数量,可设置为CPU核数+1
--IO密集型任务。这类任务有大量IO操作,伴随着大量线程被阻塞,应配置更多的线程数,可设置CPU核心数*2
- 线程池原理
线程池的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。主要特点为:线程复用;控制最大并发数;管理线程。
线程复用:每一个 Thread 的类都有一个 start 方法。 当调用 start 启动线程时 Java 虚拟机会调用该类的 run方法。 那么该类的 run() 方法中就是调用了 Runnable 对象的 run() 方法。 我们可以继承重写Thread 类,在其 start 方法中添加不断循环调用传递过来的 Runnable 对象。 这就是线程池的实现原理。循环方法中不断获取 Runnable 是用 Queue 实现的,在获取下一个 Runnable 之前可以是阻塞的。
以下任务提交逻辑来自ThreadPoolExecutor.execute方法:
1.如果运行的线程数 < corePoolSize,直接创建新线程,即使有其他线程是空闲的。
2.如果运行的线程数 >= corePoolSize:
2.1如果插入队列成功,则完成本次任务提交,但不创建新线程。
2.2如果插入队列失败,说明队列满了:
2.2.1如果当前线程数 < maximumPoolSize,创建新的线程放到线程池中。
2.2.2如果当前线程数 >= maximumPoolSize,会执行指定的拒绝策略。
- 阻塞队列的策略
直接提交。SynchronousQueue是一个没有数据缓冲的BlockingQueue,生产者线程对其的插入操作put必须等待消费者的移除操作take。将任务直接提交给线程而不保持它们。
无界队列。当使用无限的 maximumPoolSizes 时,将导致在所有corePoolSize线程都忙时新任务在队列中等待。
有界队列。当使用有限的 maximumPoolSizes 时,有界队列(如ArrayBlockingQueue)有助于防止资源耗尽,但是可能较难调整和控制。
- 守护线程
Java提供两种线程:守护线程、用户线程。
守护线程/服务进程/精灵线程/后台线程:在程序运行时在后台提供通用服务的线程,不属于程序中不可或缺的部分。
只要有任何非守护线程还在运行,程序就不会终止。
将一个用户线程设置为守护线程:在调用start() 方法启动线程之前调用对象的setDaemon(true)方法
- 如何在两个线程间共享数据
--将数据抽象成一个类,并将对这个数据的操作作为这个类的方法,这么设计可以和容易做到同步,只要在方法上加 synchronized。
--将 Runnable 对象作为一个类的内部类,共享数据作为这个类的成员变量,每个线程对共享数据的操作方法也封装在外部类,以便实现对数据的各个操作的同步和互斥,作为内部类的各个 Runnable 对象调用外部类的这些方法。
- ThreadLocal
ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。
ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap又包含了一个Entry数组,Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对的能力。弱引用的目的是为了防止内存泄露,如果是强引用那么ThreadLocal对象除非线程结束否则始终无法被回收,弱引用则会在下一次GC的时候被回收。
但是这样还是会存在内存泄露的问题,假如key和ThreadLocal对象被回收之后,entry中就存在key为null,但是value有值的entry对象,但是永远没办法被访问到,同样除非线程结束运行。不过只要ThreadLocal使用恰当,在使用完之后调用remove方法删除Entry对象,实际上是不会出现这个问题的。
让每个线程内部都会维护一个ThreadLocalMap,里边包含若干了 Entry(K-V 键值对),每次存取都会先获取到当前线程ID,然后得到该线程对象中的Map,然后与Map交互。
作用:提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。
使用场景:用来解决 数据库连接、Session 管理等。
- 线程调度
抢占式调度:每条线程执行的时间、线程的切换都由系统控制。系统控制指的是在系统某种运行机制下,可能每条线程都分同样的执行时间片,也可能是某些线程执行的时间片较长,甚至某些线程得不到执行的时间片。一个线程的堵塞不会导致整个进程堵塞。
协同式调度:协同式调度指某一线程执行完后主动通知系统切换到另一线程上执行。线程的执行时间由线程本身控制,线程切换可以预知,不存在多线程同步问题。一个线程堵塞,可能导致整个系统崩溃。
- CAS
CAS(Compare And Swap/Set)比较并交换,属于乐观锁,主要是通过处理器的指令来保证操作的原子性。
CAS 算法的过程是这样:它包含 3 个参数CAS(V, E, N)。V 表示要更新的变量(内存值),E 表示旧值,N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N。如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。
缺点:
--CAS 会导致“ABA 问题”。CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题。ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。
--循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。
--只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。
- 锁的类型
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,可能会造成优先级反转(高优先级被低优先级阻塞)或者饥饿现象(等待时间过长)。
ReentrantLock通过构造函数指定该锁是否是公平锁,默认是非公平锁。Synchronized是一种非公平锁。
独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。
ReentrantLock是独享锁。Synchronized是独享锁。ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
互斥锁/读写锁
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。互斥锁在Java中的具体实现就是ReentrantLock,读写锁在Java中的具体实现就是ReadWriteLock。
乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
悲观锁适合写操作非常多的场景。乐观锁适合读操作非常多的场景。
悲观锁在Java中的使用,就是利用各种锁。乐观锁在Java中的使用,是CAS算法。
可重入锁
指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
ReentrantLock, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。
Synchronized,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
偏向锁/轻量级锁/重量级锁
针对Synchronized的锁状态:
偏向锁:指一段同步代码一直被一个线程所访问,在无竞争情况下把整个同步都消除掉。
目的:为了减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。
轻量级锁:指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过CAS自旋的形式尝试获取锁,不会阻塞。
目的:为了减少无实际竞争情况下,使用重量级锁产生的性能消耗。
重量级锁:指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。
自旋锁/自适应自旋锁
指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁。
这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
默认自旋次数为10。自适应自旋锁的自旋次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定,是虚拟机对锁状况的一个预测。
- 并发包工具类
CountDownLatch
计数器闭锁是一个能阻塞主线程,让其他线程满足特定条件下主线程再继续执行的线程同步工具。
使用场景:
并行计算:把任务分配给不同线程之后需要等待所有线程计算完成之后主线程才能汇总得到最终结果。
模拟并发:可以作为并发次数的统计变量,当任意多个线程执行完成并发任务之后统计一次即可。
Semaphore
信号量是一个能阻塞线程且能控制统一时间请求的并发量的工具。比如能保证同时执行的线程最多200个,模拟出稳定的并发量。
由于同时获取3个许可,所以即使开启了100个线程,但是每秒只能执行一个任务。
使用场景:数据库连接并发数,如果超过并发数,等待(acqiure)或者抛出异常(tryAcquire)
CyclicBarrier
可以让一组线程相互等待,当每个线程都准备好之后,所有线程才继续执行的工具类
与CountDownLatch类似,都是通过计数器实现的,当某个线程调用await之后,计数器减1,当计数器大于0时将等待的线程包装成AQS的Node放入等待队列中,当计数器为0时将等待队列中的Node拿出来执行。
与CountDownLatch的区别:
CountDownLatch是一个线程等其他线程,CyclicBarrier是多个线程相互等待。
CyclicBarrier的计数器能重复使用,调用多次。
使用场景: 有四个游戏玩家玩游戏,游戏有三个关卡,每个关卡必须要所有玩家都到达后才能允许通过。其实这个场景里的玩家中如果有玩家A先到了关卡1,他必须等到其他所有玩家都到达关卡1时才能通过,也就是说线程之间需要相互等待。