读书笔记之《实战Java虚拟机》(8):锁与并发

锁介绍

线程安全

线程安全,指在多线程环境下,无论多个线程如何访问目标对象,目标对象的状态应该始终是保持一致的,线程的行为也总是正确的。

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

/**
 * @author caojiantao
 */
public class Main {

    public static void main(String[] args) throws InterruptedException {
        List<Integer> list = new ArrayList<>();
        int loop = 100_000;
        new Thread(() -> {
            int count = 0;
            while (count++ < loop) {
                list.add(0);
            }
        }).start();
        new Thread(() -> {
            int count = 0;
            while (count++ < loop) {
                list.add(0);
            }
        }).start();
        Thread.sleep(10_000);
    }
}
复制代码

两个线程同时向 list 集合中添加数据,由于 ArrayList 不是线程安全的,因此程序运行后,很有可能抛出下列错误(也有可能正常):

Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 2776
	at java.util.ArrayList.add(ArrayList.java:463)
	at Main.lambda$main$0(Main.java:16)
	at java.lang.Thread.run(Thread.java:748)
复制代码

因为两个线程同时对 ArrayList 进行写操作,破坏了 ArrayList 内部数据的一致性,可以使用 Vector 代替。

对象头和锁

每个对象都有一个对象头,用来保存对象的系统信息。对象头中有一个称为 Mark Word 的部分,可以存放对象的哈希值、对象年龄、锁的指针等信息。

锁的实现和优化

偏向锁

JDK 1.6 提出的一种锁优化方式。核心思想是,如果程序没有竞争,则取消之前已经取得锁的线程同步操作。也就说,若某个锁被线程获取后,自动进入偏向模式,当这个线程再次请求这个锁时,无须再进行相关的同步操作,从而节省了操作时间。如果在此期间有其他线程进行了锁请求,则锁退出偏向模式。使用 -XX:+UseBiasedLocking 启用偏向锁。

import java.util.List;
import java.util.Vector;

/**
 * @author caojiantao
 */
public class Main {

    public static void main(String[] args) {
        List<Integer> list = new Vector<>();
        long timeMillis = System.currentTimeMillis();
        long count = 10_000_000L;
        int i = 0;
        while (i++ < count) {
            list.add(i);
        }
        System.out.println(System.currentTimeMillis() - timeMillis);
    }
}
复制代码

使用下列参数启动:

-Xms512m -Xmx512m -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
复制代码

输出结果:

252
复制代码

由于虚拟机默认在启动后 4 秒后,才启用偏向锁,故设置 delay 为 0。

调整参数,禁用偏向锁:

-Xms512m -Xmx512m -XX:-UseBiasedLocking
复制代码

输出结果

348
复制代码

锁在竞争激烈的场合下,很难一直保持在偏向模式,因此偏向锁不仅不能提高性能,反而可能会降低。因此在激烈竞争的场合,可以尝试使用 -XX:-UseBiasedLocking 参数禁用偏向锁。

轻量级锁

如果偏向锁失败,Java 虚拟机会让线程申请轻量级锁。如果加锁失败,则轻量级锁有可能被膨胀为重量级锁。

锁膨胀

当轻量级锁失败,虚拟机就会使用重量级锁。

膨胀过程中,线程很可能会在操作系统层面被挂起。如果这样,线程间切换和调度的成本就会比较高。

自旋锁

锁膨胀后,虚拟机会做最后的争取,使用自旋锁避免线程被操作系统挂起。

自旋锁可以使线程没有取得锁时,不被挂起,而转去执行一个空循环(自旋)。在若干个空循环后获取到锁,线程继续执行,否则被挂起。

在单线程锁占用时间较长的并发程序,自旋锁往往自旋过后仍然无法获得对应的锁。不仅仅浪费的 CPU 时间,还不能避免被挂起,从而浪费了系统资源。

JDK 1.6 中,使用 -XX:+UseSpinning 开启自旋锁,使用 -XX:PreBlockSpin 设置自旋锁的等待次数。
JDK 1.7 中,自旋锁的参数被取消,虚拟机总是会执行自旋锁,自旋次数也由虚拟机自行调整。

锁消除

虚拟机在 JIT 编译时,通过对运行上下文的扫描,去除不可能存在的共享资源竞争的锁,节省毫无意义的请求锁时间。

/**
 * @author caojiantao
 */
public class Main {

    public static void main(String[] args) {
        long timeMillis = System.currentTimeMillis();
        int count = 10_000_000;
        int i = 0;
        while (i++ < count) {
            test();
        }
        System.out.println(System.currentTimeMillis() - timeMillis);
    }

    // 不可能存在竞争
    private static void test(){
        StringBuffer buffer = new StringBuffer();
        buffer.append("a");
    }
}
复制代码

启用锁消除:

-XX:+EliminateLocks
复制代码

输出结果:

184
复制代码

禁用锁消除:

-XX:-EliminateLocks
复制代码

输出结果:

233
复制代码

锁优化

减少锁持有时间

public synchronized void syncMethond() {
    fun1();
    mutextFun();
    fun2();
}
复制代码

假设只有 mutextFun() 需要同步,在并发量较大的时候,使用整个方法做同步的方案,会导致等待线程大量增加。

只在有必要时进行同步,减少线程持有锁时间,提高系统吞吐量:

public void syncMethond() {
    fun1();
    synchronized(this){
        mutextFun();
    }
    fun2();
}
复制代码

JDK 中 Pattern 类就有这样的例子:

public Matcher matcher(CharSequence input) {
    if (!compiled) {
        synchronized(this) {
            if (!compiled)
                compile();
        }
    }
    Matcher m = new Matcher(this, input);
    return m;
}
复制代码

减小锁粒度

典型使用场景就是 ConcurrentHashMap 类的实现,将整个 HashMap 分成了若个段(Segment),每个段都是一个子 HashMap。例如在添加数据时,首先根据 hashcode 得到该项需要存放到哪个段中,然后对该段进行加锁,而不是将整个 HashMap 锁住。这样不同段的数据操作互不影响,从而提高并发能力。

锁分离

根据应用程序的特点,将一个独占锁分成多个锁,典型案例就是 LinkedBlockingQueue 的实现。

take() 和 put() 分别实现了从队列中获取数据和往队列中增加数据的功能。由于基于链表,两个操作分别作用于队列的前端和尾端,两者并不冲突。因此在 JDK 的实现中,拆分成了 take() 和 put() 两把锁:

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
复制代码

锁粗化

虚拟机在一连串连续对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数。这个操作叫做锁的粗化。

for(int i = 0; i < CIRCLE; i++) {
    synchronized(lock){
        
    }
}
复制代码

上述代码在每个循环中,都对同一个对象申请锁,可以使用锁粗化:

synchronized(lock){
    for(int i = 0; i < CIRCLE; i++) {
        
    }
}
复制代码

无锁

CAS

比较并交换(Compare And Swap),简称 CAS。该算法包含 3 个参数(V, E, N),V 指需要更新的变量,E 指变量预期的值,N 表示新值。仅当 V 等于 E 时,才将 V 设置为 N。

原子操作

JDK 在 java.util.concurrent.atomic 包下,提供了一系列 CAS 操作的实现类。以 AtomicInteger 的 getAndSet 方法为例:

public final int getAndSet(int newValue) {
    for (;;) {
        int current = get();
        if (compareAndSet(current, newValue))
            return current;
    }
}
复制代码

JDK 1.8 的实现稍有不同,CAS 的逻辑移到了 Unsafe 类中实现。

LongAddr

结合前文介绍的减小锁粒度的实现,LongAddr 将内部核心数据分离成一个数组,热点数据进行了有效的分离,提高了并行度。

理解 Java 内存模型

原子性

原子性中的原子代表不可分割的意思,原子操作是不可中断的,也不能被多线程干扰。例如 int 等数据的赋值操作就具备原子性,但是像“a++”这样的操作就不具备原子性,它涉及到读取 a、计算新值和写入 a 三步操作,中间有可能被其他线程干扰。

long 和 double 占 64 位,因而在 32 位操作系统中读和写均不具备原子性。

有序性

CPU 为保障指令流畅执行,有可能会对目标指令进行重排。重排不会导致单线程中的语义修改,但会导致多线程中的语义出现不一致。

比如以下语句不能进行重排:

  • 写后读:a=1; b=a;
  • 写后写:a=1; a=2;
  • 读后写:a=b; b=1;

可见性

可见性是指当一个线程修改了一个变量的值,另外一个线程可以马上得知这个修改。

由于系统编译器优化,部分变量的值可能会被寄存器或者高速缓冲(Cache)缓存,而每个 CPU 都拥有独立的寄存器和 Cache,从而导致其他线程无法立即发现这个修改。

/**
 * @author caojiantao
 */
public class Main {

    private static class MyThread extends Thread {
        private boolean stop = false;
        public void stopMe(){
            stop = true;
        }

        @Override
        public void run() {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("stop");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyThread thread = new MyThread();
        thread.start();
        Thread.sleep(1000);
        thread.stopMe();
        Thread.sleep(1000);
    }
}
复制代码

期望结果是调用 stopMe() 后,while 循环结束,打印“stop”,但是实际运行程序发现程序一直无法结束。这就是由于在主线程中对 stop 变量的修改无法反应到 MyThread 线程中去。

可以增加 volatile 修饰 stop 变量,或者使用 synchronized 修饰 stopMe() 方法,都可以解决线程间可见性问题。

Happens-Before 原则

虽然虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的。

  • 程序顺序原则:一个线程内保证语义的串行性;
  • volatile 规则:volatile 变量的写,先发生于读,保证了 volatile 变量的可见性;
  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前;
  • 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C;
  • 线程的 start() 方法先于它的每一个动作;
  • 线程的所有操作先于线程的终结;
  • 线程的终端先于被终端线程的代码;
  • 对象的构造函数执行结束先于 finalize() 方法;

转载于:https://juejin.im/post/5c97729ff265da610614877a

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值