多线程中的乐观锁、悲观锁

本文介绍了多线程中的乐观锁和悲观锁概念。乐观锁通过版本号控制和CAS(比较并交换)实现,适用于多读少写场景,而悲观锁主要使用synchronized关键字,确保事务完整性。文章详细讲解了CAS的工作原理及其在Java atomic包中的应用,并对比了两者在不同场景下的适用性。此外,还提到了读写锁、线程安全的单例模式以及线程池的作用和实现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

多线程中,共享变量存放于共享内存,各个线程会将其copy到自己的线程内存中,更改数据,最后把线程内存中的数据复制给共享内存。

应用于多线程的乐观锁
乐观的以为每次拿取数据时别人都不会更改数据,但在更新的时候也会以某种方法判断一下有没有人更新这个数据。乐观锁应用于多读少写的场景。
判断有没有人更新数据有两种方法:版本号控制 和 CAS(compare and swap)比较和交换

版本号控制
比如,给数据加一个版本字段,凡线程对其进行数据更新后,版本号+1,当另一个线程对其更新时,会比对一下,共享内存中该数据的版本号,和当前线程中的版本号是否一致,不一致则说明有其他线程对其进行了更新,之后他要重新从内存中获取数据,再进行后续操作。
这个套路也可以用于数据库中数据的更改。

CAS
CAS有三个参考值
V:变量在共享内存中的值
A:线程本地内存的值
B:线程模拟修改变量后的值
CAS机制在更新一个变量的时候,只有当变量在线程本地内存的值A和共享内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。它与版本号控制原理类似,只不过它比较的是值而不是版本号。CAS的开销很大,它的底层有一个自旋锁(其实就是一个while判断),如果V不等于A的时候,它会一直循环,直到V等于A。CAS不能保障代码块的原子性。

CAS会引起ABA问题
假设,三个线程,一个共享变量A,线程1、3会把A变成C,如果线程1执行完后,共享变量变为C,线程3发现共享内存的值C 与 我线程内存的值A 不相等,所以线程3就不会执行,但是这时出现了一个线程2,它在线程1之后、线程3之前,将C又变回了A,那么之后线程3在执行时,发现原本内存的值A又和线程3内存值A相等了,线程3又会执行了,它将A变成了C,但这个C不是通过线程1得到的C。

实际场景,假设有100元,我开启了两个线程,他们执行的内容是,当有100元时,减去50元,这样,理论上两个线程都执行完后,还剩下50元,也就是只扣了一个50元。如果执行当中恰巧有另一个线程乱入了,这个线程执行的内容是增加50元,这个线程刚好排在了那两个线程中间执行,那么,最后一个线程执行时,他发现,-50又+50,共享内存中是100,那他符合执行条件,V=A,它也会执行一遍,最终得到的结果却是,100元被扣了2次50元。

ABA问题 通常的解决办法是添加版本号或时间戳,每次修改操作时版本号加一,这样数据对比的时候就不会出现 ABA 的问题了。

CAS在JAVA中的应用:java.util.concurrent.atomic包
比如 i++ 在多线程中的操作,如不使用sychronize修饰,将会出现问题。
假设100个线程执行对共享内存变量A=0的+1操作,得到结果往往会小于100,因为执行过程中,某个线程+1后,可能后面另一个线程已将其结果存在了共享内存中,当前线程再去存结果值,这样就会导致共享内存中的结果值变小(因为它有可能覆盖了另一个的结果值,这导致结果值比真实结果少了1)

public class Counter {
    public volatile  static int count = 0;
    public static void inc(){
        try{
            Thread.sleep(1); //延迟1毫秒
        }catch (InterruptedException e){ //catch住中断异常,防止程序中断
            e.printStackTrace();
        }
        count++;//count值自加1
    }

    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(100);
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Counter.inc();
                    latch.countDown();
                }
            }).start();
        }
        latch.await();
        System.out.println("运行结果:"+Counter.count);
   }
}
运行结果:98

由于atomic包中的方法是基于CAS来实现的,所以在线程执行时,它会判断V与A值是否相等,然后才会执行,如果不等则while自旋,直到它符合条件,才执行。

public class Counter {
    public static AtomicInteger count = new AtomicInteger(0);
    public static void inc(){
        try{
            Thread.sleep(1); //延迟1毫秒
        }catch (InterruptedException e){ //catch住中断异常,防止程序中断
            e.printStackTrace();
        }
        count.getAndIncrement();//count值自加1
    }

    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(100);
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Counter.inc();
                    latch.countDown();
                }
            }).start();
        }
        latch.await();
        System.out.println("运行结果:"+Counter.count);
    }
}
结果:100

CAS与sychronize的比较
CAS适用于写操作少的、资源竞争较少的情况,syschronize适用于写操作多、线程冲突严重的情况。
注意:如果CAS涉及的线程很多,比如说有1000个线程去处理i++自增,那么在一个线程执行后,就有可能有999个线程在自旋(资源竞争多),过多的线程自旋会严重销毁CPU资源,这种情况下就不适合再使用CAS了,应改用sychronize关键字。
JAVA后期版本对sychronize进行了优化,使之在线程竞争少的情况下,也能获得和CAS类似的性能了。

ReadWriteLock读写锁
ReentrantReadWriteLock是ReadWriteLock接口的具体实现类
读写锁的特点是:同一时刻允许多个线程对共享资源进行读操作;同一时刻只允许一个线程对共享资源进行写操作;当进行写操作时,同一时刻其他线程的读操作会被阻塞;当进行读操作时,同一时刻所有线程的写操作会被阻塞。对于读锁而言,由于同一时刻可以允许多个线程访问共享资源,进行读操作,因此称它为共享锁;而对于写锁而言,同一时刻只允许一个线程访问共享资源,进行写操作,因此称它为排他锁。
简单来说就是,有读锁的时候,不能写,但能读,有写锁的时候,不能读也不能写
可以降锁但不能升锁,比如以下的升锁会锁死。

  ReadWriteLock rtLock = new ReentrantReadWriteLock();
 rtLock.readLock().lock();
 rtLock.writeLock().lock();

它常被应用于构建共享缓存类中

class CachedData {
  Object data; //缓存的数据
  volatile boolean cacheValid; //缓存是否存在
  final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); //读写锁

  public void processCachedData() {
    rwl.readLock().lock(); //开启读锁
    if (!cacheValid) {
      rwl.readLock().unlock(); //关闭读锁
      rwl.writeLock().lock(); //开启写锁
      try {
        if (!cacheValid) {
          data = ...
          cacheValid = true;
        }
        // 在释放写锁之前通过获取读锁降级写锁(注意此时还没有释放写锁)
        rwl.readLock().lock();
      } finally {
        rwl.writeLock().unlock(); // 释放写锁而此时已经持有读锁
      }
    }

    try {
      use(data);
    } finally {
      rwl.readLock().unlock();
    }
  }
}

悲观锁
悲观锁:使用synchronized来解决多线程并发问题,以保证事务的完整性。线程在访问加锁的代码块,只会让一个线程进入,其他线程只能等待或者去执行其他没有加锁的代码块。这个线程执行完成会释放锁,由下一个线程来执行。性能慢。
如果是实例对象方法加锁的话,要对同一个实例才有效,不然不同对象各锁各的,相当于没锁。

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

sychronize的三种用法
修饰实例方法,针对同一对象的被修饰方法时会对实例上锁
修饰静态方法
修饰代码块

线程安全的单例模式

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {    }
    public static Singleton getUniqueInstance() {       
    	//先判断对象是否已经实例过,没有实例化过才进入加锁代码
    	if (uniqueInstance == null) {            
    	//类对象加锁            
    		synchronized (Singleton.class) {                
    			if (uniqueInstance == null) {                    
    				uniqueInstance = new Singleton();               
   				}           
   			}        
   		}        
   		return uniqueInstance;    
   	} 
}

其中voliate关键字的作用:
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
禁止进行指令重排序。
变量声明为 volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存(即共享内存)中进行读取,而不是从线程内存中读取

可重入锁
可重入锁就是一个类的A、B两个方法,A、B都有获得统一把锁,当A方法调用时,获得锁,在A方法的锁还没有被释放时,调用B方法时,B方法也获得该锁。
sychronize和ReentrantLock都是可重入锁
可重入锁可以防止死锁。(比如子类方法中调用父类方法,可重入锁保证了被调用的父类方法也获得了子类方法涉及的锁)
不可重入锁
不可重入锁就是一个类的A、B两个方法,A、B都有获得统一把锁,当A方法调用时,获得锁,在A方法的锁还没有被释放时,调用B方法时,B方法也获得不了该锁,必须等A方法释放掉这个锁。

线程池的作用
1.减少资源消耗(因为重复的创建、销毁会造成过多的资源消耗)
2.提高相应速度(有需求时直接拿来一个现成的线程)
3.提高线程可管理性
原理:线程池本质是一个hashSet。多余的任务会放在阻塞队列中

AQS (AbstractQueuedSynchronizer)队列同步器
Java 中常用的并发包的底层技术,想像共享内存中的值被一个线程调用,那么这个值就会被加锁,如果有其他线程此时访问该值,则将这个线程放到一个阻塞的队列中,等待锁释放,然后再执行这个线程
synchronized 和 Lock 锁之间的区别(Lock是并发包)
在这里插入图片描述
并发量很多的场景,Lock锁更稳定。
并发量很少的场景,sychronize与Lock锁差不多,sychronize胜在应用简单。

线程池需实现Runnable或Callable接口,前者不会返回结果,后者会。相对应的 excute() 方法提交不需返回值的任务,submit()提交有返回值的任务,返回的是future对象。

三种线程池
通过Executor 框架的工具类Executors来实现
1.FixedThreadPool ,固定大小的线程池
2.SingleThreadExecutor, 池内只有一个,多余的会报存在一个队列中
3.CachedThreadPool,根据实际情况调整大小的线程池

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

缔曦_deacy

码字不易,请多支持

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值