悲观锁:
1、理解:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿到资源的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
共享资源每次只给一个线程使用,其他的线程阻塞,用完后再把资源转让给其他线程。
2、技术实现:Java中的 synchronized 关键字 和 ReentrantLock 类 等独占锁就是悲观锁思想的实现。
3、应用场景:传统关系型数据库(Mysql,oracle)里面就用到了很多这种锁机制,比如行锁、表锁,读锁(共享锁)、写锁(排他锁)等,都是在做操作之前先上锁。
一般来说,当并发访问和修改的冲突情况比较频繁、数据更新的冲突概率较高时,应该采用悲观锁,以防止读写冲突,保障数据的安全性。(如银行转账等操作)
4、优点:可以保证共享资源的数据一致性和完整性,避免多个线程同时修改共享资源导致的数据混乱和错误。
5、缺点:可能会因为加锁和解锁导致死锁、性能问题、资源争用等问题,锁冲突问题多。
乐观锁:
1>、理解:总是假设最好的情况,每次去拿数据的时候都认为 别人不会去修改,所以不会上锁,但是在更新数据的时候会去判断一下在此期间别人有没有去更新这个数据。
2>、技术实现: 版本号机制 和 CAS 算法 都是乐观锁的实现方式。
3>、应用场景:
1、乐观锁适用于写比较少的情况(多读场景),这样可以提高吞吐量
2、NoSQL非关系型数据库提供的类似 write_condition 机制,其实都是提供的乐观锁
当读操作远远超过写操作的情况下,应该优先考虑采用乐观锁,因为悲观锁需要先假定数据发生了冲突,然后再加锁进行保护,这种加锁操作会影响系统的性能,乐观锁适用于数据冲突的概率较小的情况。(如微博访问等)
4>、优点:
1、减少锁冲突(通过数据版本控制的方式来保证数据在多个线程中的一致性)
2、性能高(不需要进行加锁、解锁等操作,可提高系统的并发量和吞吐量)
3、方便实现(版本号控制或者时间戳等方式对数据的一致性进行控制)
4、允许并发访问(允许多个线程同时访问同一份数据,只有在数据版本号发生变化时才会发生异常,从而保证了数据在并发访问时的一致性和完整性)
5>、缺点:
1、自旋CAS算法导致的ABA问题:
一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,但是在这段时间内它的值可能被修改成其他的值,然后又被改回A,那么CAS操作就会误认为它从来没有被修改过,这个问题就被称为CAS操作的ABA问题
2、循环时间长开销大:
自旋CAS(就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
(优化方法:如果JVM能支持处理器提供的pause指令(cpu指令,让线程在执行的过程中主动暂停一段时间),那么效率就可以提升)
3、只能保证一个共享变量的原子操作:
CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。
但是JDK1.5之后有个AtomicReference类,利用这个类我们可以将多个变量放在一个对象里面来进行CAS操作,将多个共享变量合并成一个共享变量来操作
CAS算法:
1、概念:
cas是Java中Unsafe类中的一个方法,即 compare and swap(比较与交换),是一种有名的无锁算法。就是在不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。
cas算法涉及到三个操作数:需要读写的内存值V,进行比较的值A,要写入的新值B,cas会一直自旋的判断V值是否等于A值,相同才会写入新值,不相同就重新获取最新的内存值进行业务逻辑操作后,再进行比较。
示例:
大白话:
1、线程A和线程B都要去获取内存值进行++操作
2、线程A拿到内存值V时V=0,然后就去对V值执行++操作
3、此时线程B也获取到内存值V,获取到的V值也=0,然后也去执行++操作,然后线程B执行速度比较快,执行完业务逻辑++操作后,就再去拿一遍内存值V,把两次获取到的V值进行对比,如果一致,说明线程B在执行++操作的这段时间内,内存值V的值没有被其他线程修改过,所以线程B就可以把自己执行完++操作后的最新值赋值给内存值V,此时内存值V就被线程B从0修改成1了。
4、然后此时的线程A也执行完++操作了,也同样再次去获取内存值V,此时线程A获取到的内存值V已经是被线程B修改过的1了,所以和刚开始获取到的V值(0)进行对比,发现不相等,说明在线程A执行++操作的时候,已经有其他线程把内存值V的值修改了,所以此时的线程A就不能把刚刚执行完++的值赋值给V了。
5、线程A就只能再重新去获取内存值V进行++操作,此时线程A拿到的内存值V = 1,然后再重新++操作,操作完再去获取最新的内存值(如果没被其他线程修改过,则内存值V = 1),再拿来跟执行++操作前获取到的V值(V=1)进行对比,如果对比一致,说明这次在线程A执行++操作的过程中没有其他线程修改过内存值B,所以线程A就可以把自己执行++操作的值赋值给内存值。
6、如果线程A对比发现一开始获取到的内存值和执行完业务逻辑后再去获取的内存值 不一致,那么就继续获取最新的内存值,再继续做业务逻辑操作,操作完再继续拿最新的内存值和一开始的进行对比,这个过程就叫做自旋。
版本号机制:
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改的时候,version值就会+1。
当线程A要更新数据值的时候,在读取数据的同时也会读取version值,在提交更新的时候会再次读取那条数据的version值,如果两个version值一样的话,才会更新数据,否则就重试刚刚的更新数据操作,直到更新成功。
因为如果没有对比version值,那么线程A在修改数据的期间,有其他线程修改相同的那条数据并提交,那么就会导致数据出错,出现旧数据覆盖新数据等这种情况。
提交版本的version值必须要大于记录当前版本的version值才能执行更新。
write_condition 机制:
可以先理解为是版本号机制的一种体现(版本号机制是为表添加一个版本号的字段,write_condition是根据表中的某个字段的状态来判断数据是否被修改过)
时间戳:
MySQL中的timestamp,或者自己在表中新增一个字段用于存储时间戳,就是在执行数据更新时,除了比较版本号之外,还需要比较时间戳。在执行数据更新时,同时需要更新时间戳的数据,
一般有两种方式:
一种是在数据库层面通过触发器等方式自动更新时间戳字段,
另一种是在应用层面手动更新时间戳字段
ReentrantLock 类:
就是创建一个类,ReentrantLock.lock()获取锁,然后try/catch写代码,ReentrantLock.unlock()释放锁。
当使用 ReentrantLock 时需要手动获取和释放锁,否则会导致死锁或其他问题。因此建议使用 try…finally 结构来保证锁的正确释放:
独占锁:
独占锁是同步锁的一种。同步锁是一种在多线程环境下同步访问共享资源的机制,其主要目的是为了避免多个线程同时对共享资源进行读写操作而导致的数据不一致性问题。独占锁是同步锁的一种,它在同一时间只允许一个线程访问共享资源,其他线程必须等待当前线程释放锁之后才能访问共享资源。
在Java中实现独占锁的方式是通过synchronized关键字或者ReentrantLock类进行实现。当一个线程获得了锁之后,其他线程就必须等待它释放锁之后才能尝试获取锁。独占锁的使用可以有效地避免多个线程同时访问共享资源而导致的数据不一致性问题,但是也会带来一定的性能损失和死锁的风险,需要在实际应用中进行权衡和优化。
synchronized 关键字:
实现方式:方法用 synchronized关键字修饰,这个方法就算是加锁了,可以叫实例锁、对象锁。这个方法在任意时刻只能允许一个线程访问。
流程理解: A线程想调用这个方法,就必须先获取到这个实例关联的锁(对象锁、实例锁),获取到后才能执行方法里面的代码,其他线程如果也想访问这个方法,但是这个方法已经被A线程获取,其他线程就会被阻塞,直到A线程执行完,释放锁之后,其他线程才能获取锁。
锁的获取方式:是通过Java虚拟机底层的监视器机制实现的,不需要我们写代码,在我们去调用这个方法的时候,底层自己就会去获取锁,如果已经被其他线程获取了,就会进行等待,直到锁被释放。
当线程A调用一个被synchronized修饰的方法,但是该锁已经被其他线程占用时,线程A会进入到对象的monitor状态,等待获取锁的机会。每个对象都拥有monitor这个监视器锁。monitor是用来协调多个线程对该对象的访问。通过synchronized关键字修饰的方法或者代码块就是使用该对象的monitor监视器锁来实现锁功能的。
在等待的过程中,线程A会被放置在对象的monitor里面的等待队列中,等待获取锁的机会。当该锁被释放时,Monitor就会通知等待队列中的一个或多个线程,来争夺锁的获取。如果这个时候线程A争到这个锁,就可以执行被synchronized关键字修饰保护的代码,否则就继续回到等待队列等待,直到获取到锁为止。
释放锁:
线程A执行完按方法里面的代码后,会自动释放锁的(当线程A执行完同步代码块之后,虚拟机会自动将获取锁的标识清除)。
线程A也可以调用wait()方法主动释放锁。
如果线程A在执行完synchronized
修饰的代码块之后,没有通知(通过调用notify()
或notifyAll()
方法)其它等待的线程,那么其它线程就没办法知道锁什么时候会被释放,就会无限期的等待下去。