Java高并发实战小白必会系列1:核心基础细节

1,729 阅读4分钟

JMM基础

原子性:

原子性是指一个操作是不可中断的,即使在多个线程同时执行的情况下,一个操作一旦开始,就不会被其他线程干扰。对于基本数据类型(如int),在大多数情况下,它们的读写操作是原子性的。然而,对于long型数据,在32位JVM系统中,其读写操作不是原子性的,因为long有64位。这意味着,如果两个线程同时对long进行写入(或读取)操作,它们之间的结果可能会受到干扰。

为了避免这种情况,可以使用volatile关键字或者java.util.concurrent.atomic包中的原子类(如AtomicLong)来确保long型数据的读写操作具有原子性

可见性

对于一个线程A的修改变量t,其他线程B可以及时看到最新的t的值。但是由于以下情况,B无法及时看到最新修改的t的值:

  1. 线程A修改值没有及时写入内存
  2. 线程B从cpu cache中读值
  3. 指令重排导致

对于3,举个例子

public class Example {
    static int a = 0;
    static boolean flag = false;

    public static void writer(){
        a += 1;
        flag = true;
    }

    public static void reader(){
        if(flag){
            System.out.println(a);
        }
    }
}

假设有线程A执行wirter,B执行reader, 由于第6-7行可能发生指令重排,所以B线程在12行打印出的a不能保证一定是1,也可能是0;

有序性

对于一个线程来说,它看到的指令执行顺序一定是一致的 (否则的话我们的应用根本无法正常工作)。也就是说指令重排是有一个基本前提的, 就是保证串行语义的一致性。指令重排不会使串行的语义逻辑发生问题。因此,在串行代码中, 大可不必担心。

注意:指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。

需要关注的关键字/API

Object对象的wait和notify

Object.wait()和 Thread.sleep()方法都可以让线程等待若干时间。除了 wait()可以被唤醒外,另外一个主要区别就是 wait()方法会释放目标对象的锁,而Thread.sleep()方法不会释放任何资源。

思考:

1.为什么java中wait方法需要在synchronized的方法中调用?

答:调用wait()就是释放锁,释放锁的前提是必须要先获得锁,先获得锁才能释放锁。

2.为什么notify(),notifyAll()必须在同步(Synchronized)方法/代码块中调用?

答:程调用notify(),notifyAll()方法是将等待队列的线程转移到入口队列,然后让他们竞争锁,所以这个调用线程本身必须拥有锁

详见:面试突击24:为什么wait和notify必须放在synchronized中? - 磊哥|www.javacn.site - 博客园 (cnblogs.com)

Volatile

不保证原子性。详见:www.cnblogs.com/zhengbin/p/…

ReentrantLock

image.png

思考:

1.condition的await是否会释放线程占有的reentrantLock? 答:会的。

2.为什么condition的await方法建议被try,catch处理?

public static void main(String[] args) throws InterruptedException {
    ReentrantLock lock = new ReentrantLock();
    Condition condition = lock.newCondition();
    Runnable task = ()->{
        lock.lock();
        System.out.printf("Thread.currentThread().getName()"+ Thread.currentThread().getName());
        try {
            condition.await();
        } catch (InterruptedException e) {
            // 程序在运行8行时发生中断异常,则不会释放lock的锁。
            // 详见condition.await()方法源码。故需要主动释放锁
            lock.unlock();
            throw new RuntimeException(e);
        }
    };

    Thread t1 = new Thread(task,"t1");
    Thread t2 = new Thread(task,"t2");
    t1.start();
    // 主线程休眠2s,可以看到t2线程也可以成功运行第6行,说明t1线程在第8行释放锁了
    Thread.sleep(2000); 
    t2.start();

}

必看经典用法,ArrayBlockingQueue源码。

信号量:允许多个线程访问一个资源

读写锁:允许读读并行的重入锁

CountDownLaunch:等待所有线程执行完成再执行主线程;CyclicBarry:支持循环多次使用。每次达到计算器个数后执行任务。

线程阻塞工具:LockSupprot

LockSupport 可以在线程内任意位置让线程阻塞。和Thread.suspend()相比,它弥补了由于resume()在前发生,导致线程无法继续执行的情况。和Object.wait()相比,它不需要先获得某个对象的锁,也不会抛出 InterruptedException 异常.

好处:先unpark()操作再park()操作,也能保证线程不会被阻塞。因为LockSupport 类使用类似信号量的机制。它为每一个线程准备了一个许可,如果许可可用,那么park()函数会立即返回,并且消费这个许可(也就是将许可变为不可用),如果许可不可用,就会阻塞。而unpark()则使得一个许可变为可用(但是和信号量不同的是,许可不能累加,你不可能拥有超过一个许可,它永远只有一个)。

所以,线程阻塞优先使用LockSupprot.park,unpark

下期更新系列2:进阶用法,主要介绍线程池和并发容器