并发、线程安全


 

概念、理论

并发:多个线程操作相同的资源,优点:效率高、资源利用率高,缺点:线程可能不安全、数据可能不一致,需要使用一些方式保证线程安全、数据一致

高并发:服务器能同时处理大量请求

线程安全:当多个线程访问某个类,不管采用何种调度方式、线程如何交替执行,这个类都能表现出正确的行为。

 

造成线程不安全的原因

  • 存在共享资源
  • 多个线程同时操作同一共享资源,操做不具有原子性

 

如何实现线程安全?

  • 使多线程不同时操作同一共享资源:eg. 只使用单线程、必要的部分加锁、使用juc的并发容器、并发工具类
  • 使对共享资源的操作具有原子性:eg.使用原子类
  • 不共享资源:eg. 使用ThreadLocal
  • 用final修饰共享资源,使之只读、不可修改

只要实现以上任意一点,即可实现线程安全

 

互斥锁的特性

  • 互斥性:同一时刻只能有1个线程对这部分数据进行操作,互斥性也常叫做操作的原子性
  • 可见性:如果多个线程同时操作相同的数据(读、写),对数据做的修改能及时被其它线程观测到。可见性用happens-before原则保证

 

锁的实现原理

在这里插入图片描述
获取锁:把主内存中对应的共享资源读取到本地内存中,将主内存中的该部分共享资源置为无效
释放锁:把本地内存中的资源刷到主内存中,作为共享资源,把本地内存中的该部分资源置为无效

 

juc包简介

juc包提供了大量的支持并发的类,包括

  • 线程池executor
  • 锁locks,locks包及juc下一些常用类CountDownLatch、Semaphore基于AQS实现。jdk将同步的通用操作封装在抽象类AbstractQueuedSynchronizer中,acquire()获取资源的独占权(获取锁),release()释放资源的独占权(释放锁)
  • 原子类atomic,atomic包基于CAS实现,实现了多线程下无锁操作
  • 并发容器(集合)collections
  • 并发工具类tools

 

实现线程安全的常用方式

synchronized

synchronized的用法
// 修饰普通方法
public synchronized void a(){

}

// 修饰静态方法
public static synchronized void b(){

}


public static Object lock = new Object();

public void c(){
    // 修饰代码块。同步代码块,锁住一个对象
    synchronized (lock){
        
    }

}

synchronized可以修饰方法、代码块,修饰的操作是原子性的,同一时刻只能有1个线程访问、执行

  • 修饰普通方法,执行该方法时会自动锁住该方法所在的对象
  • 修饰静态方法,加的是类锁,执行该方法时会锁住所在类的class对象,即锁住该类所有实例
  • 修饰代码块,加的是对象锁,会锁住指定对象

如果要修饰方法,尽量用普通方法,因为静态方法因为会锁住类所有的实例,严重影响效率。

 

synchronized的实现原理

synchronized使用对象作为锁,对象在内存的布局分为3部分:对象头、实例数据、对齐填充,对象头占64位

  • 前32位是Mark Word,存储对象的hashCode、gc分代年龄、锁类型、锁标志位等信息
  • 后32位是类型指针,存储对象所属的类的元数据的引用,jvm通过类型指针确定此对象是哪个类的实例
     

Mark Work结构如下
在这里插入图片描述
每个对象都关联了一个Monitor(这也是为什么每个对象都可以作为锁的原因),锁的指针指向对象对应的Monitor,当某个线程持有锁时,Monitor处于锁定状态

 

synchronized的4种锁状态及膨胀方向

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  • 无锁:没有线程要获取锁,未加锁
  • 偏向锁:大多数情况下,锁不存在多线程竞争,很多时候都是同一线程多次申请锁。偏向锁简化了线程再次申请锁的流程,减少了同一线程多次获取同一个锁的代价。偏向锁只适用于锁竞争不激烈的情况
  • 轻量级锁:适用于锁竞争一般的情况
  • 重量级锁:适用于锁竞争激烈的情况
    在这里插入图片描述

 

使用Lock接口

synchronized使用前自动加锁、使用完自动释放锁,很方便。synchronized是悲观锁的实现,每次操作共享资源前都要先加锁;以前是重量级锁,性能低,经过不断优化,量级轻了很多,性能和Lock相比差距不再很大。

Lock需要自己加锁、用完需要自己释放。Lock是乐观锁的实现,每次先操作共享资源,提交修改时再验证共享资源是否被其它线程修改过;Lock是轻量级锁,性能很高。

Lock接口有很多实现类,常用的有ReentrantLock 可重入锁、ReadWriteLock 读写锁,也可以自己实现Lock接口来实现自定义的锁。

 

ReentrantLock 可重入锁

重入:一个线程再次获取自己已持有的锁

public class Xxx{
    public final static ReentrantLock lock=new ReentrantLock(); //锁对象都可以加个final防止被修改
    //public final static ReentrantLock lock=new ReentrantLock(true);  //可指定是否是公平锁,缺省时默认false

    public void a() {
        lock.lock();  //获取锁,如果未获取到锁,会一直阻塞在这里
        // lock.tryLock();  //只尝试1次,如果未获取到锁,直接失败不执行后面的代码
        
        //....   //操作共享资源
        
        lock.unlock();  //释放锁
    }
    
    public void b() {
        try {
            lock.tryLock(30, TimeUnit.SECONDS);  //如果获取锁失败,会在指定时间内不停尝试。此句代码可能会抛出异常
            //.... //操作共享资源
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            if (!lock.isFair()){
                lock.unlock();  //如果获取到锁,最终要释放锁
            }
        }
    }

    public void c() {
        lock.lock();
        try {
            //.... //操作共享资源
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();  //如果操作共享资源时可能发生异常,最终要释放锁
        }
    }
    
}

 

ReentrantLock如何实现公平锁、非公平锁?

使用链表存储等待同一把锁的线程,将线程添加到链表尾部,释放锁后

  • 公平锁:将锁分配给链表头部的线程
  • 非公平锁:将锁分配个链表中的任意一个线程

将获得锁的线程从链表中移出

 

synchronized、ReentrantLock的比较
  • synchronized是关键字,ReentrantLock是类
  • 机制不同,synchronized是操作对象的Mark Word,ReentrantLock是使用Unsafe类的park()方法加锁
  • synchronized是非公平锁,ReentrantLock可以设置是否是公平锁
  • ReentrantLock可以实现比synchronized更细粒度的控制,比如设置锁的公平性
  • 锁竞争不激烈时,synchronized的性能往往要比ReentrantLock高;锁竞争激烈时,synchronized膨胀为重量级锁,性能不如ReentrantLock
  • ReentrantLock可以设置获取锁的等待时间,避免死锁

 

ReadWriteLock 读写锁

ReadWriteLock将锁细粒度化分为读锁、写锁,synchronized、ReentrantLock 同一时刻最多只能有1个线程获取到锁,读锁同一时刻可以有多个线程获取锁,但都只能进行读操作,写锁同一时刻最多只能有1个线程获取锁进行写操作,其它线程不能进行读写操作。

读写锁做了更加细致的权限划分,加读锁时多个线程可以同时对共享资源进行读操作,相比于synchronized、ReentrantLock,在以读为主的情况下可以提高性能。

ReadWriteLock是接口,常用的实现类是ReentrantReadWriteLock 可重入读写锁。

public class Xxx {
    public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); 
    public static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();  //从读写锁获取读锁
    public static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();  //从读写锁获取写锁
    //.....

    public void a(){
        //....

        readLock.lock();
        //..... 操作共享资源
        readLock.unlock();
        
        //....
    }

}

读锁、写锁的操作方式和ReentrantLock完全相同,都可以设置超时,这3种锁都是可重入锁

 

锁降级

在获取写锁后,写锁可以降级为读锁

public class Xxx {
    public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); 
    public static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();  //读锁
    public static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();  //写锁
    //.....

    public void a(){
        //....

        writeLock.lock();  //获取写锁
        //..... 对共享资源进行写操作
        readLock.lock();  //获取读锁(仍然持有写锁)
        writeLock.unlock();  //释放写锁(只持有读锁,写锁->读锁,锁降级)
        //.....  //对共享资源进行读操作
        readLock.unlock();  //释放读锁
        
        //....
    }

}
  • 锁降级后,线程仍然持有写锁,需要自己释放写锁
  • 锁降级的意义在于:后续对共享资源只进行读操作,及时释放写锁可以让其它线程也能获取到读锁、进行读操作
  • 锁降级的应用场景:对数据比较敏感,在修改数据之后,需要校验数据
  • 写锁可以降级为读锁,但读锁不能升级为写锁

 

AQS如何用int值表示读写状态

AbstractQueuedSynchronizer,抽象类

int,4字节32位,高位(前16位)表示读锁状态,低位(后16位)表示写锁状态。状态指的是重入次数,最大为2^16-1=65536

 

StampedLock

StampedLock是jdk1.8新增的类,可以获取读写锁、读锁、写锁,可以选择悲观锁、乐观锁,但StampedLock是不可重入的,且API比其他方式复杂,使用难度稍高。

 

ThreadLocal

ThreadLocal维护了一个map,这个map中存储的数据是当前线程独有的。ThreadLocal可以保证各个线程的数据互不干扰,并发场景下可以实现无状态调用,适用于各个线程依赖不同的变量值完成操作的场景。

public class Xxx {
    private static ThreadLocal<Integer> i = ThreadLocal.withInitial(() -> 100); //必须要初始化值

    public void a() {
        i.set(20);  //设置值
        Integer value = i.get();  //获取值
        i.remove();  //移出set()赋的值,重置为初始化时的值,即100
    }

}

 

volatile

volatile的使用
public static volatile boolean flag = true;  //禁止对此变量进行指令重排序

volatile只能修饰变量,实现了该变量的可见性、可以禁止指令重排序,当该变量的被某个线程修改时会自动通知其它使用此变量的线程。

volatile只实现了可见性,没有实现原子性,严格来说并没有实现线程安全,一般只用于

  • 作为开关 ,eg. while(flag){ }
  • 在懒汉式单例中修饰对象实例,禁止指令重排序

 

volatile、synchronized的比较

在这里插入图片描述
 

原子类

i++、++i、i–、--i、+=、-=等操作都不是原子性的,juc的atomic包下的类提供了自增、自减、比较赋值、取值修改等原子性方法,可以线程安全地进行操作,因为类中的方法都是原子性的,所有叫做原子类。

public class Xxx {

    public static AtomicInteger i = new AtomicInteger(0);  //int
    public static AtomicLong l = new AtomicLong(0);  //long
    public static AtomicBoolean b = new AtomicBoolean(false);  //boolean
    public static AtomicReference<User> user = new AtomicReference<>(new User());  //引用

    public static AtomicIntegerArray intArr = new AtomicIntegerArray(new int[]{1, 23});  //int[ ]
    public static AtomicLongArray longArr = new AtomicLongArray(new long[]{1, 23});  //long[ ]

    public static AtomicIntegerFieldUpdater<User> userId1 = AtomicIntegerFieldUpdater.newUpdater(User.class,"id");  //对象的int型字段
    public static AtomicLongFieldUpdater<User> userId2 = AtomicLongFieldUpdater.newUpdater(User.class,"id");  //对象的long型字段
    public static AtomicReferenceFieldUpdater<User, List>  userOrderList= AtomicReferenceFieldUpdater.newUpdater(User.class, List.class,"orderList");  //对象的引用型字段
    
}
  • 原子类使用CAS实现乐观锁,并发支持好、效率高
  • CAS提交修改失败时会while循环进行重试,如果重试时间过长,会给cpu带来很大开销
  • 可能发生ABA问题。有2个原子类解决了ABA问题 :AtomicMarkableReference、AtomicStampedReference,使用标记、邮戳实现乐观锁,和版本号、时间戳机制差不多,避免了ABA问题。
  • 只能保证单个变量的原子性,只能进行简单操作,如果要保证多个变量、稍微复杂点的操作的原子性,要用其它方式来实现线程安全(一般是加锁)
     

并发容器

Vector、Hashtable 的方法都使用synchronized修饰,是线程安全的,但缺点较多,基本不使用这2个类。

Collections.synchronizedXxx()可以将集合转换为同步集合,是使用synchronized锁住整个集合,效率低下,不推荐。

juc提供了常用的并发容器,使用CAS保证线程安全,效率高,常见的并发容器如下

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();  //有序,按照插入顺序排列,内部使用Object[]存储元素
CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>(); //无序,CopyOnWriteArraySet内部使用CopyOnWriteArrayList存储元素

ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();  //map

ConcurrentLinkedQueue<String> queue1 = new ConcurrentLinkedQueue<>();  //基于链表的队列

LinkedBlockingQueue<String> queue2 = new LinkedBlockingQueue<>();  //基于链表的阻塞队列,如果参数指定了元素个数,则有界、不能扩容,如果未指定,则无界

ArrayBlockingQueue<String> queue3 = new ArrayBlockingQueue<>(20);  //基于数组的阻塞队列,指定容量,不能扩容(有界)
ArrayBlockingQueue<String> queue4 = new ArrayBlockingQueue<>(20,true);  //可以指定是否是公平锁,默认false

阻塞指的是,在进行某些操作时,会阻塞线程

 
在生产者/消费者的线程协作模式中,常用阻塞队列LinkedBlockingQueue作为仓库

LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();  //基于链表的阻塞队列
        
//入队的3个方法
queue.offer("");  //返会操作结果,boolean,如果队列满了放不下,返回false
queue.add("");  //返会操作结果,boolean,如果队列满了放不下,会抛出异常
try {
    queue.put("");  //如果队列满了,会阻塞线程,直到队列元素变少、可以放进去
} catch (InterruptedException e) {
    e.printStackTrace();
}


//出队的3个方法
queue.poll();  //如果队列是空的,返回null
queue.remove();  //如果队列是空的,会抛出异常
try {
    queue.take();  //在队列为空的时候,会阻塞线程,直到有元素可弹出
} catch (InterruptedException e) {
    e.printStackTrace();
}

 

并发工具类

CountDownLatch

CountDownLatch是一个计数器,常用于等待某些线程执行完毕

CountDownLatch countDownLatch = new CountDownLatch(2);  //指定次数

new Thread(()->{
    //.....
    countDownLatch.countDown();  //次数-1
}).start();


new Thread(()->{
    //......
    countDownLatch.countDown();
}).start();


try {
    countDownLatch.await();  //阻塞当前线程,直到次数为0时才继续往下执行,即等待2个线程执行完毕
    //......
} catch (InterruptedException e) {
    e.printStackTrace();
}

 

CyclicBarrier 栅栏

CyclicBarrier cyclicBarrier = new CyclicBarrier(3);  //指定await的线程数

new Thread(()->{
    //......
    try {
        cyclicBarrier.await();  //第一个
        //.....
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (BrokenBarrierException e) {
        e.printStackTrace();
    }
}).start();


new Thread(()->{
    //......
    try {
        cyclicBarrier.await();  //第二个
        //.....
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (BrokenBarrierException e) {
        e.printStackTrace();
    }
}).start();


//.....
try {
    cyclicBarrier.await();  //第三个
    //.....
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (BrokenBarrierException e) {
    e.printStackTrace();
}

线程执行到await()处会阻塞,停下来,直到指定数量的线程都执行到await()才会继续往下执行。

CountDownLatch用于一些线程等待另一些线程执行完毕,类似超市收银员等待顾客挑好东西来结账;CyclicBarrier用于指定数量的线程互相等待,类似于大家指定地点集合。

 

Semaphore 信号量

Semaphore用于限流

Semaphore semaphore = new Semaphore(2);  //指定信号量
// Semaphore semaphore = new Semaphore(2,true);  //可指定是否使用公平锁,默认false

new Thread(() -> {
    //......
    try {
        semaphore.acquire();  //使用1个信号量,信号量-1。如果信号量为0,没有可用的信号量,阻塞线程直到获取到信号量
        //....
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        semaphore.release();  //操作完释放信号量,信号量+1
    }
}).start();

 

Exchanger

交换机,用于2条线程之间交换数据,只能用于2条线程之间,即一个Exchanger对象只能被2条线程使用(成对)

Exchanger<String> stringExchanger = new Exchanger<>();  //泛型指定交换的数据类型

new Thread(()->{
    try {
        String data = stringExchanger.exchange("are you ok?");
        System.out.println("线程1接收到的数据:" + data);  //ok
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

new Thread(()->{
    try {
        String data = stringExchanger.exchange("ok");
        System.out.println("线程2接收到的数据:" + data);  //are you ok
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

 

单例与线程安全

单例有2种模式

  • 饿汉式:在类加载时就实例化,线程安全
  • 懒汉式:在需要使用实例时才实例化,可能是线程不安全的

 
饿汉式

//饿汉式
public class A {
    private static A a=new A();  //用静态成员保存实例,调用构造方法创建实例。类加载时会初始化静态成员
    //.....  

    private A(){  //构造方法私有化,隐藏掉

    }

    public static A getInstance(){  //把获取实例的方法暴露出去
        return a;  //只有1步,原子性,线程安全
    }

    //.....  
}

 

懒汉式

//懒汉式  写法一
class A {
    private static A a;  //用静态成员保存实例
    //.....   

    private A(){  //构造方法私有化,隐藏掉

    }

    public static A getInstance(){  //暴露获取实例的方法,多步,不具有原子性,不是线程安全的
        if (null==a){  
            a = new A();  
        }
        return a;
    }

    //.....   

}

 
写法二:用synchronized修饰获取获取实例的静态方法,但这种方式获取实例时会锁住类,使多个线程不能同时获取实例,效率低下
 

//懒汉式  写法三
class A {
    private static volatile A a;  //volatile禁止指令重排序
    //.....   

    private A(){

    }

    public static A getInstance(){
        if (null==a){
            synchronized (A.class){  //优化写法,只在创建实例时锁住类
                a = new A();
            }
        }
        return a;
    }

    //.....  

}

 

锁的分类

  • 自旋锁:未获取到锁时进入等待状态,多线程切换上下文会消耗系统资源,频繁切换上下文不值得,jvm会在线程没获取到锁时,暂时执行空循环等待获取锁,即自旋,循环次数即自旋次数;如果在指定自旋次数内没获取到锁,则挂起线程,切换上下文,执行其它线程。锁默认是自旋的。

  • 自适应自旋锁:自旋次数不固定,由上一次获取该锁的自旋时间及锁持有者的状态决定,更加智能
     

  • 阻塞锁:阻塞锁会改变线程的运行状态,让线程进入阻塞状态进行等待,当获得相应信号(唤醒或阻塞时间结束)时,进入就绪状态
     

  • 重入锁:已持有锁的线程,在未释放锁时,可以再次获取到该锁

public class Xxx{
    public final static ReentrantLock lock=new ReentrantLock();

    public void a() {
        lock.lock();
        //.....
        b();  //如果锁是可重入的,则b()直接获取到锁;如果锁不是可重入的,则b()需要单独获取获取锁,但锁还没被a()释放,b()会一直获取不到锁
        //.....
        lock.unlock();
    }

    public void b() {
        lock.lock();
        //......
        lock.unlock();
    }

}

 

  • 读锁:是一种共享锁 | S锁(share),多条线程可同时操作共享资源,但都只能进行读操作、不能进行写操作

  • 写锁:是一种排它锁 | 互斥锁 | 独占锁 | X锁,同一时刻最多只能有1个线程可以对共享资源进行写操作,其它线程不能对该资源进行读写
     

  • 悲观锁:每次操作共享资源时,认为期间其它线程一定会修改共享资源,每次操作共享数据之前,都要给共享资源加锁

  • 乐观锁:每次操作共享资源时,认为期间其它线程一般不会修改共享资源,操作共享资源时不给共享资源加锁,只在提交修改时验证数据是否被其它线程修改过,常用版本号等方式实现乐观锁
     

  • 公平锁:等待锁的线程按照先来先得顺序获取锁(慎用)

  • 非公平锁:释放锁后,等待锁的线程都可能获取到锁,不是先来先得

非公平锁可能导致某些线程长时间甚至一直获取不到锁,但这种情况毕竟是极少数;使用公平锁,为保证公平性有额外的开销,会降低性能,所以一般使用非公平锁
 

  • 偏向锁:初次获取锁后,锁进入偏向模式,当获取过锁的线程再次获取该锁时会简化获取锁的流程,即锁偏向于曾经获取过它的线程

 

锁消除:编译时会扫描上下文,自动去除不可能存在线程竞争的锁

锁细化:如果只操作共享资源的一部分,不用给整个共享资源加锁,只需给要操作的部分加锁即可。使用细粒度的锁可以让多个线程同时操作共享资源的不同部分,提高效率。

锁粗化:要操作共享资源的多个部分,如果每次只给部分加锁,频繁加锁、释放锁会影响性能,可以扩大锁的作用范围,给整个共享资源加锁,避免频繁加锁带来的开销。

 

指令重排序

指令重排序:编译器、处理器会对指令序列重新排序,提高执行效率、优化程序性能

int a=1;
int b=1;

以上2条指令会被重排序,可能2条指令并发执行,可能int a=1;先执行,可能int b=1;先执行。
 

指令重排序遵循的2个原则

1、 数据依赖性,不改变存在数据依赖关系的两个操作的执行顺序。

int a=1;
int b=a;

b依赖于a,重排序不能改变这2个语句的执行顺序
 

2、as-if-serial原则,重排序不能改变单条线程的执行结果

int a=1;
int b=a;

执行结果是a=1、b=1,重排序后执行得到的也要是这个结果

 

数据同步接口

有时候需要对接第三方的项目,或者公司大部门之间对接业务,不能直接连接、操作他们的数据库,一般是建中间库|中间表,把我们|他们需要的数据放到中间库|表中,去中间库|表获取数据。更新数据库时需要同步更新中间库|表。
 

中间表的设计

  • 只存储要使用的字段即可
  • 需要用一个字段记录该条数据的状态:已入库、正在处理、处理时发生异常、已处理
  • 需要用一个字段记录数据入库时间
  • 需要用一个字段记录处理时间

记录时间是为了日后好排查问题、统计分析

 

对中间表的处理

可以使用生产者/消费者的线程协作模式

  • 生产者分批读取中间表中未处理的数据 where status=‘xxx’,放到仓库中。因为数据量一般很大,所以通常要分批读取,防止仓库装不下。如果要操作多张表,很多操作都差不多,可以抽象出接口
  • 消费者处理仓库中的数据

操作时需要更新中间表中的数据状态、处理时间

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章