【Java并发编程 六】Java线程安全与同步方案(中)

简介: 【Java并发编程 六】Java线程安全与同步方案(中)

悲观锁的实现方式

悲观锁的实现方式也就是加锁,加锁既可以在代码层面(比如Java中的synchronized关键字),也可以在数据库层面(比如MySQL中的排他锁)

乐观锁的问题

CAS虽然很高效,但是它也存在三大问题,这里简单说一下:

  1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从A-B-A变成了1A-2B-3A。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
  3. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。但是还是没有使用synchronized方便

二者的适用场景

乐观锁与悲观锁相比,适用的场景受到了更多的限制,无论是CAS机制还是版本号机制。

  • 乐观锁的功能有限,比如,CAS机制只能保证单个变量操作的原子性,当涉及到多个变量的时候,CAS机制是无能为力的,而synchronized却可以通过对整个代码块进行加锁处理;再比如,版本号机制如果在查询数据的时候是针对表1,而更新数据的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
  • 竞争激烈程度,在竞争不激烈(出现并发冲突的概率比较小)的场景中,乐观锁更有优势。因为悲观锁会锁住代码块或数据,其他的线程无法同时访问,必须等待上一个线程释放锁才能进入操作,会影响并发的响应速度。另外,加锁和释放锁都需要消耗额外的系统资源,也会影响并发的处理速度。在竞争激烈(出现并发冲突的概率较大)的场景中,悲观锁则更有优势。因为乐观锁在执行更新的时候,可能会因为数据被反复修改而更新失败,进而不断重试,造成CPU资源的浪费

总的来说乐观锁可以当做美图秀秀,悲观锁可以当做PS,简单场景乐观锁OK,大量场景下还是会存在问题

自旋锁和非自旋锁

自旋锁(spinlock),是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU,其实和CAS的操作类似,也是乐观锁的一种实现形式。

自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快

非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

package com.nuih.lock;
import sun.misc.Unsafe;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
 * 题目:实现一个自旋锁
 * 自旋锁好处:循环比较获取直到成功为止,没有类似wait的阻塞
 *
 * 通过cas操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒钟,
 * B随后进来后发现当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到
 */
public class SpinLockDemo {
    // 原子引用
    AtomicReference<Thread> atomicReference = new AtomicReference<>();
    public void myLock(){
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "\t come in ");
        while (!atomicReference.compareAndSet(null,thread)){
        }
    }
    public void myUnLock(){
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread,null);
        System.out.println(thread.getName() + "\t invoked myUnLock");
    }
    public static void main(String[] args) throws InterruptedException {
        SpinLockDemo spinLockDemo = new SpinLockDemo();
        new Thread(() -> {
            spinLockDemo.myLock();
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            spinLockDemo.myUnLock();
        },"A").start();
        TimeUnit.SECONDS.sleep(1);
        new Thread(() -> {
            spinLockDemo.myLock();
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            spinLockDemo.myUnLock();
        },"B").start();
    }
}

比较与适用场合

自旋和非自旋之间存在如下差异且因此在不同的场合使用:

  • 自旋锁不会进入内核态,避免了线程上下文切换的开销,而非自旋锁会切换内核态
  • 自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间

如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次

自适应自旋

在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长时间,比如100个循环。
  • 如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源

不过这也仅仅为局部优化,是一种基于预测的优化

公平锁和非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象

  • Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
  • Synchronized也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

二者的实现方式有差异:

  • 公平锁的实现:并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后按照FIFO的规则从队列中取到自己
  • 非公平锁的实现:非公平锁,直接尝试占有锁,如果尝试失败,就再采用类似公平锁的那种方式

吞吐量大的情况下还是选择非公平锁

可重入锁(递归锁)

可重入锁(也叫做递归锁)指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码。同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码快。可重入锁的最大作用是避免死锁

package com.nuih.lock;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class MyPhone implements Runnable {
    public synchronized void sendMsg(){
        System.out.println(Thread.currentThread().getName() + "\t invoked sendMsg");
        sendEmail();
    }
    public synchronized void sendEmail(){
        System.out.println(Thread.currentThread().getName() + "\t##### invoked sendEmail");
    }
    Lock lock = new ReentrantLock();
    @Override
    public void run() {
        get();
    }
    public void get(){
        lock.lock();
        try{
            System.out.println(Thread.currentThread().getName() + "\t invoked get");
            set();
        }finally {
            lock.unlock();
        }
    }
    public void set(){
        lock.lock();
        try{
            System.out.println(Thread.currentThread().getName() + "\t###### invoked set");
        }finally {
            lock.unlock();
        }
    }
}
/**
 * 可重入锁(也叫做递归锁)
 *
 * 指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,
 * 在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
 *
 * 也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码快。
 *
 * t1 invoked sendMsg()        t1线程在外层方法获取锁的时候
 * t1 ##### invoked sendEmail  t1在进入内层方法会自动获取锁
 *
 * t2 invoked sendMsg()
 * t2 ##### invoked sendEmail
 */
public class ReentrantLockDemo {
    public static void main(String[] args) throws InterruptedException {
        MyPhone myPhone = new MyPhone();
        new Thread(() -> {
            myPhone.sendMsg();
        },"t1").start();
        new Thread(() -> {
            myPhone.sendMsg();
        },"t2").start();   //Synchronized的可重入实现
        TimeUnit.SECONDS.sleep(1);
        System.out.println("\n\n\n\n");
        Thread t3 = new Thread(myPhone,"t3"); //ReentrantLock的可重入实现
        t3.start();
        Thread t4 = new Thread(myPhone,"t4");
        t4.start();
    }
}

在上面代码段中,执行 sendMsg方法需要获得当前对象作为监视器的对象锁,但方法中又调用了 sendEmail的同步方法。

  • 如果锁是具有可重入性的话,那么该线程在调用 sendEmail时并不需要再次获得当前对象的锁,可以直接进入 sendEmail方法进行操作。
  • 如果锁是不具有可重入性的话,那么该线程在调用 sendEmail前会等待当前对象锁的释放,实际上该对象锁已被当前线程所持有,不可能再次获得。

如果锁是不具有可重入性特点的话,那么线程在调用同步方法、含有锁的方法时就会产生死锁。

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。对于Java ReentrantLock和Synchronized而言,其是独享锁。但是对于Lock的另一个实现类ReentrantReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

上面讲的独享锁/共享锁是一种广义的说法,互斥锁/读写锁就是具体的实现。互斥锁在Java中的具体实现就是ReentrantLock读写锁在Java中的具体实现就是ReentrantReadWriteLock

package com.nuih.lock;
import com.nuih.map.HashMap;
import com.nuih.map.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class MyCache{
    private volatile Map<String,Object> map = new HashMap<>();
    final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    public void put(String key,Object value){
        readWriteLock.writeLock().lock();
        try{
            System.out.println(Thread.currentThread().getName() + "\t 开始写入:" + key);
            map.put(key,value);
            System.out.println(Thread.currentThread().getName() + "\t 写入完成");
        }finally {
            readWriteLock.writeLock().unlock();
        }
    }
    public void get(String key){
        readWriteLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t 开始读取");
            Object result = map.get(key);
            System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + result);
        }finally {
            readWriteLock.readLock().unlock();
        }
    }
}

如果只进行读取

/**
 * 多个线程同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行
 * 如果又一个线程想去写共享资源了,就不应该再有其它线程可以对该资源进行读或写
 * 小总结:
 *      读-读可以共存
 *      读-写不能共存
 *      写-写不能共存
 *
 *      写操作:原子+独占,中间过程必须一个完整的统一体,中间不许被分割
 */
public class ReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache myCache = new MyCache();
        for(int i=0;i<5;i++){
            int finalI = i;
            new Thread(() -> {
                myCache.get(finalI+"");
            },"B"+String.valueOf(i)).start();
        }
    }
}

打印结果为,可以看到线程是交替执行的

B0   开始读取
B4   开始读取
B1   开始读取
B3   开始读取
B2   开始读取
B2   读取完成:null
B3   读取完成:null
B1   读取完成:null
B4   读取完成:null
B0   读取完成:null

如果只进行写入:

public class ReadWriteLockDemo {
    public static void main(String[] args) {
        MyCache myCache = new MyCache();
        for(int i=0;i<5;i++){
            int finalI = i;
            new Thread(() -> {
                myCache.put(finalI+"",finalI);
            },"A"+String.valueOf(i)).start();
        }
    }
}

打印结果为,可以看到线程是挨个执行的。

A1   开始写入:1
A1   写入完成
A0   开始写入:0
A0   写入完成
A2   开始写入:2
A2   写入完成
A3   开始写入:3
A3   写入完成
A4   开始写入:4
A4   写入完成


相关文章
|
1天前
|
Java Shell API
Java 模块化编程:概念、优势与实战指南
【4月更文挑战第27天】Java 模块化编程是 Java 9 中引入的一项重大特性,通过 Java Platform Module System (JPMS) 实现。模块化旨在解决 Java 应用的封装性、可维护性和性能问题
8 0
|
2天前
|
缓存 Java
Java并发编程:深入理解线程池
【4月更文挑战第26天】在Java中,线程池是一种重要的并发工具,它可以有效地管理和控制线程的执行。本文将深入探讨线程池的工作原理,以及如何使用Java的Executor框架来创建和管理线程池。我们将看到线程池如何提高性能,减少资源消耗,并提供更好的线程管理。
|
3天前
|
存储 安全 Java
Java并发编程中的高效数据结构:ConcurrentHashMap解析
【4月更文挑战第25天】在多线程环境下,高效的数据访问和管理是至关重要的。Java提供了多种并发集合来处理这种情境,其中ConcurrentHashMap是最广泛使用的一个。本文将深入分析ConcurrentHashMap的内部工作原理、性能特点以及它如何在保证线程安全的同时提供高并发性,最后将展示其在实际开发中的应用示例。
|
4天前
|
Java API 调度
[Java并发基础]多进程编程
[Java并发基础]多进程编程
|
4天前
|
Java API 调度
[AIGC] 深入理解Java并发编程:从入门到进阶
[AIGC] 深入理解Java并发编程:从入门到进阶
|
4天前
|
前端开发 Java 测试技术
Java从入门到精通:4.1.1参与实际项目,锻炼编程与问题解决能力
Java从入门到精通:4.1.1参与实际项目,锻炼编程与问题解决能力
|
4天前
|
SQL Java 数据库连接
Java从入门到精通:2.3.2数据库编程——了解SQL语言,编写基本查询语句
Java从入门到精通:2.3.2数据库编程——了解SQL语言,编写基本查询语句
|
4天前
|
SQL Java 数据库连接
Java从入门到精通:2.3.1数据库编程——学习JDBC技术,掌握Java与数据库的交互
ava从入门到精通:2.3.1数据库编程——学习JDBC技术,掌握Java与数据库的交互
|
4天前
|
IDE Java 开发工具
Java从入门到精通:1.3.1实践编程巩固基础知识
Java从入门到精通:1.3.1实践编程巩固基础知识
|
9天前
|
IDE Java 物联网
《Java 简易速速上手小册》第1章:Java 编程基础(2024 最新版)
《Java 简易速速上手小册》第1章:Java 编程基础(2024 最新版)
15 0