Java JUC 学习笔记

Java JUC

java.util.concurrent工具包

基础

从Future到CompletableFuture

Future

Future接口定义了异步任务执行的一些方法,如获取异步任务的结果,取消任务的执行,判断任务是否完成、是否取消等。

[使用场景]:如果主线程需要执行一个很耗时的任务,我们就可以通过future将任务放到异步线程中去执行,需要执行结果时主线程在获取即可。

FutureTask

实现了RunnableFuture<T>RunnableFuture<T>接口,通过构造函数可以传入Callable对象(可以有返回值并抛出异常),也可传入Runnable对象。

Future优缺点
  1. 优点

    future+线程池可以显著提升异步任务执行效率

  2. 缺点

    • get方法可能会阻塞,可以为get设置超时时间避免灾难情况
    • isDone方法轮询耗费性能资源。我们可以先通过isDone获取任务是否结束,结束后才调用get获取结果。但是用户代码只能轮询进行isDone调用。

阻塞的方式和异步编程的设计理念相违背,而轮询的方式会浪费CPU资源,因此JDK8设计了CompletableFuture,提供了一种观察者模式类似的机制,可以让任务执行完成后通知监听的一方。

希望获得的其他功能:

回调通知,创建异步任务,多个任务前后依赖的组合处理,选取计算最快的任务

CompletableFuture

CompletableFuture继承了接口Future和``CompletionStage`

CompletionStage
  • CompletionStage代表异步计算过程中的某一阶段,一个阶段完成以后可能会触发另一个阶段。

  • 一个阶段的计算执行可以是一个FunctionConsumer或者Runnable

  • 一个阶段的执行可能是被单个阶段的完成触发,也可能是由多个阶段一起触发

四种静态方法创建CompletableFuture
// runAsync 无返回值
public static CompletableFuture<Void> runAsync(Runnable runnable);

public static CompletableFuture<Void> runAsync(Runnable runnable,
                                               Executor executor);
// supplyAsync 有返回值
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier);

public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier,
                                                       Executor executor);

上面其中两个方法中的Executor参数是由默认值的,默认是ForkJoinPool.commonPool(),这个线程是守护线程,如果异步执行未完成但是主线程结束,那么异步任务也被取消了,因此建议使用自己定义的线程池。

// 使用案例
public class CompletableFutureDemo {
    public static void main(String[] args) {
        // 创建线程池
        // runAsync使用
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        CompletableFuture<Void> completableFuture =  CompletableFuture.runAsync(() -> System.out.println("Hello World"));

        // supplyAsync使用
        CompletableFuture<String> completableFuture2 =  CompletableFuture.supplyAsync(()->{
            return (String) "Hello World2";
        }, executorService);
        try {
            System.out.println(completableFuture2.get());
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
        executorService.shutdown();
    }
}

CompletableFuture.get和Completable.join二者的区别

都是返回泛型值,但是get编译时期检查异常,join编译时期不检查异常,也就是说使用get,则必须使用try_catch或者使用throws抛出异常

[案例] CompletableFuture实现异步查询

与单线程查询进行对比,使用lambda表达式和stream等新特性

public class CompletableFutureMallDemo {
    static List<NetMall> list = Arrays.asList(
            new NetMall("jd"),
            new NetMall("pdd"),
            new NetMall("taoTian")
    );

    static ExecutorService executorService = Executors.newFixedThreadPool(5);

    public static List<String> getPriceStepByStep(String productName) {
        return list.stream().map(netMall ->
                        String.format("product name is %s, price is %.2f, in mall %s", productName, netMall.calcPrice(productName), netMall.getMallName()))
                .collect(Collectors.toList());
    }

    public static List<String> getPriceByCompletableFuture(String productName) {
        return list.stream()
                .map(netMall -> CompletableFuture
                        .supplyAsync(() -> String.format("product name is %s, price is %.2f, in mall %s",
                                productName, netMall.calcPrice(productName), netMall.getMallName()), executorService))
                .collect(Collectors.toList())
                .stream()
                // 注意这里需要进行一次 stream -> list -> stream 的转化,否则异步执行失效
                .map(CompletableFuture::join)
                .collect(Collectors.toList());
    }

    public static void main(String[] args) {
        System.out.println("[use CompletableFuture]");
        long start = System.currentTimeMillis();
        List<String> list = getPriceByCompletableFuture("MySQL");
        long end = System.currentTimeMillis();
        for(String str : list) {
            System.out.println(str);
        }
        System.out.println("耗时" + (end - start) + "ms");

        System.out.println("[单线程 step by step]");
        start = System.currentTimeMillis();
        list = getPriceStepByStep("MySQL");
        end = System.currentTimeMillis();
        for(String str : list) {
            System.out.println(str);
        }System.out.println("耗时" + (end - start) + "ms");

        executorService.shutdown();
    }
}

class NetMall {
    private String mallName;
    public String getMallName() {
        return mallName;
    }
    public NetMall(String mallName) {
        this.mallName = mallName;
    }
    public double calcPrice(String productName) {
        try { TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException e) { throw new RuntimeException(e); }
        return ThreadLocalRandom.current().nextDouble() + productName.charAt(0) + this.mallName.charAt(0);
    }

}

其他API

CompletableFuture的API

thenRun A->B,B不依赖A的结果

thenApply,thenAccept A->B,B依赖A的结果

  1. 获得结果的触发计算

    public T getNow(T valueIfAbsent)
    // 该方法不用显示处理异常,同时该方法不会阻塞,如果future计算结束,则返回结果,否则返回预设值的结果
    
    public boolean complete(T value);
    // 主动触发计算,如果已经计算完毕,返回false;如果没有计算完成,则返回true,同时打断CompletableFuture的计算,并让其get方法返回value值
    
  2. 对计算结果进行处理

    计算结果存在依赖,两个线程串行化处理,有返回结果

    public <U> CompletableFuture<U> thenApply(
            Function<? super T,? extends U> fn);
    // 对前面supplyAsync返回的结果进一步处理
    // 对异常的处理:如果当前步骤有异常就停止处理
    
    // 使用案例
            CompletableFuture<Integer> completableFuture2 = CompletableFuture.supplyAsync(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);
                    System.out.println("第一次等待1000ms");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return (int) 1;
            }).thenApply(ret -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                    System.out.println("第二次等待500ms");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return ret + 1;
            }).thenApply(ret -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                    System.out.println("第三次等待100ms");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return ret + 1;
            }).whenComplete((ret, ex) -> {
                if(ex == null) {
                    System.out.println("获得计算结果:" + ret);
                } else {
                    ex.printStackTrace();
                    System.out.println(ex.getMessage());
                }
            });
            TimeUnit.SECONDS.sleep(3);
    // 上述代码没有使用Async是顺序执行,会阻塞
    
    public <U> CompletableFuture<U> handle(
            BiFunction<? super T, Throwable, ? extends U> fn);
    // 作用和thenApply相似,但是对异常的处理不同
    // handle有异常也可以继续执行,因为它会根据lmabde表达式中表示异常的参数来进一步处理
    
  3. 对计算结果进行消费

    接收任务的处理结果然后消费处理,无返回结果

    public CompletableFuture<Void> thenAccept(Consumer<? super T> action);
    
  4. 对计算速度进行选用

    public <U> CompletableFuture<U> applyToEither(
            CompletionStage<? extends T> other, Function<? super T, U> fn);
    // 使用案例
            CompletableFuture<String> stringCompletableFuture3 = CompletableFuture.supplyAsync(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return "testA";
            });
    
            CompletableFuture<String> stringCompletableFuture4 = CompletableFuture.supplyAsync(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return "testB";
            });
    
            CompletableFuture<String> ret = stringCompletableFuture3.applyToEither(stringCompletableFuture4, r -> r);
            System.out.println(ret.get()); // 输出testB
    
  5. 对计算结果进行合并

    public <U,V> CompletableFuture<V> thenCombine(
            CompletionStage<? extends U> other,
            BiFunction<? super T,? super U,? extends V> fn);
    
    // 使用案例
            CompletableFuture<String> stringCompletableFuture3 = CompletableFuture.supplyAsync(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return "testA";
            });
    
            CompletableFuture<String> stringCompletableFuture4 = CompletableFuture.supplyAsync(() -> {
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return "testB";
            });
    
            stringCompletableFuture3.thenCombine(stringCompletableFuture4, (x, y) -> {
                System.out.println(x + y);
                return x + y;
            });
    
CompletableFuture与线程池

对于thenRunAsyncthenApplyAsyncthenAcceptAsync等携带Async后缀的方法,其参数都会有一个可选的线程池,如果不传入线程池,则采用默认的ForkJoinPool.commonPool()

对于没有Async后缀的方法,会与前面方法的线程池保持一致

synchronized

锁对象和锁类

公平锁和非公平锁

公平锁:线程按照申请的上锁的顺序进行上锁,也就是先到先得,不存在插队或者赖着锁资源不走的情况。

非公平锁:不严格按照申请锁的顺序分配锁资源,可能会进行一些优化,也可能导致某些线程饥饿

【问题】为什么会有公平锁/非公平锁的设计?为什么默认是非公平锁?

恢复挂起的线程到其真正获取锁之间是有时间差的,从开发人员看这个时间微乎其微,但是从微处理器的角度来看这个时间其实开销很大。也就是说线程切换的开销也是我们设计线程调度策略时需要考虑的一大因素。

当采用非公平锁时,当一个线程请求锁后进入同步状态,然后释放锁。此时我们让这个刚释放锁的线程再次获得锁,就可以减少线程开销,提高处理机资源的利用率。(性能提升)

【问题】什么时候使用公平锁,什么时候使用非公平锁?

如果为了更高的吞吐量,使用非公平锁;如果业务场景有强制的"公平需求",使用非公平锁。

可重入锁

同一个线程可以重复上锁,其优点是一定程度上可以避免死锁

synchronizedReentrantLock都是可重入锁。

synchronized可重入原理:其内部会记录当前锁的拥有者,此拥有者再次获取锁就会被放行。同时使用count记录上锁次数,上锁时count加一,释放锁count减一

死锁及排查

两个及以上的线程互相等待锁

使用jps -l打印java进程号,使用jstack java进程号 查看是否存在死锁,如果存在会打印信息Found X deadLock等字样。

cmd输入jconsole进入java控制台链接进程,然后点击按钮检测

中断和LockSupport

中断相关概念

首先,一个线程不应该由其他线程来强制中断和停止,而是应该由线程自己来执行,因此Thread.stop,Thread.suspend,Thread.resume已经被废弃了。

中断只是一种协作协商机制,Java没有给中断增加任何语法,中断的过程完全需要程序员自己实现。如果要中断一个线程,需要手动调用该线程的interrupt方法,该方法内部也仅仅只是将线程对象的中断标志设置为true,接着需要程序员自己编写代码不断检测线程的标志位,如果发现自己中断线程被设置为true,则表示有别的线程希望自己中断,至于最后是否中断,程序员自己决定。

中断相关API

Thread类的方法

public void interrupt();
// 设置现场中断标志位true,相当于发起中断协商
public static boolean interrupted();
// 静态方法,判断线程是否被中断并清除当前中断状态
public boolean isInterrupted();
// 仅仅检测线程中断标志

关于interrupt方法的说明

  1. 如果线程处于活动状态,该方法会将线程中断标志设置为true。而被设置标志的线程将继续正常执行,这意味着真正实现中断的操作需要又程序员调用其他API实现。
  2. 如果线程处于被阻塞状态(例如sleep,wait或者join等),在别的线程调用该线程的interrupt方法,那么该线程会立即退出阻塞同时清除interrupt状态并且抛出InterruptException异常。因此,在捕获InterruptException异常后需要手动二次调用interrupt方法用来保持中断状态
  3. 中断不活动的线程不会产生任何影响isInterrupted返回false。

静态方法interrupted的理解:

首先该方法会返回线程的中断状态,然后清空线程的中断状态。这意味着如果一个线程处于被打断状态,那么连续两次调用该方法时返回值是不一样的。

LockSupport的概念

java.util.concurrent.locks包下,用于创建锁和其他同步类的基本线程阻塞原语

唤醒线程的三种方法
  1. 使用Object中的wait方法让线程等待,使用Object中的notify方法唤醒线程。但是如果想要使用这两个方法,[异常]必须要让放在synchronized代码块中,同时wait方法和notify方法的调用顺序必须有严格限制,否则wait之后将无法被唤醒。
  2. 使用JUC包中的Condition的await方法让线程等待,使用signal方法唤醒线程,这种方式和上面的有同样的异常行为
  3. 使用LookSupport静态方法parkunpark让线程等待和唤醒,这种方式解决了上面的两种异常情况,使用更加方便。LookSupport使用了一种通行证(permit)的概念,一个线程调用park方法,底层会检测它是否拥有通信证,如果拥有则继续执行,如果为拥有,则阻塞在park方法内。获取通信证的方法只能通过调用unpark(Thread t)方法,并且同一个线程同一时刻最多只能拥有一个通信证。

Java内存模型JMM

JVM规范中试图定义一种Java内存模型(Java Memory Model, JMM),来屏蔽各种硬件和操作系统的内存访问差异,从而实现让Java程序在各种平台下都能达到一致的内存访问效果。

JMM本身是一种抽象概念而并不真实存在,它仅仅描述的是一组约定或者规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式,并决定一个线程对共享变量的写入时机以及对别的线程的可见性。关键技术是围绕多线程的原子性、可见性和有序性展开的。

JMM三大特性
  1. 可见性

    当一个线程修改了某一个共享变量的值,其他线程是否能立即知道该变更,JMM规定了所有的变量都存储字主内存中。

  2. 原子性

    一个操作是不可被打断的,即多线程环境下,操作不能被其他线程干扰。

  3. 有序性

    为了提升性能,编译器和处理器会对指令进行重新排序。Java规定JVM线程内部维持顺序化语义,即只要程序的最终结果与他顺序化执行的结果相同,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。并发情况下指令重排序可能导致数据脏读,因此有些时候需要手动禁止指令重排序。

happens-before

如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系执行的结果一致,那么这种重排序是合法的。

八条规则
  1. 次序规则

    一个线程内,按照代码的顺序,写在前面的操作先行发生于写在后面的操作。也就是说前面指令执行的结果对后面指令是可见的。

  2. 锁定规则

    unlock操作先行发生于后面对同一个锁的lock操作,也就是说锁只有被释放才能被再次上锁。

  3. volatile变量规则

    对一个volatile变量的写操作先行发生于对这个变量的读操作。

  4. 传递规则

    A先于B,B先于C,则A先于C

  5. 线程启动规则

    Thread对象的start方法先行发生于此线程的每一个动作。

  6. 线程中断规则

    对线程interrupt方法的调用先行发生于被中断线程的代码检测到被中断事件的发生。

  7. 线程终止规则

    线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过isAlive等方法检测线程是否已经终止

  8. 对象终结规则

    用C++特性解释,构造函数先行于析构函数

volatile

特点

可见性和禁止重排,但是它不保证原子性

volatile内存语义
  • 当写一个volatile变量时,JMM会将线程对应的本地内存中的共享值立即刷新回主内存中
  • 当读一个volatile变量时,JMM会将线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量值。
内存屏障

内存屏障之前的写操作都要写回到主内存,

内存屏障之后的读操作都能获得内存屏障之前所有写操作的最新结果(实现了可见性)

因此重排序时,不允许将内存屏障之后的指令重排序到内存屏障之前。换言之,对一个volatile变量的写,先行发生于任意后续volatile变量的读。

屏障的底层是通过C/C++嵌入汇编实现的

volatile读写行为

当第一个操作为volatile读时,无论第二个操作是什么,都不能重排序。保证volatile读操作之后的操作不会被重排到volatile读之前

当第二个操作为volatile写时,无论第一个操作是什么,都不能重排序。保证volatile写操作之前的操作不会被重排到volatile写之后

当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。

读/写屏障

读屏障,在每个volatile读操作之后依次插入LoadLoad和LoadStore屏障;

写屏障,在每个volatile写操作之前插入StoreStore屏障,在写操作之后加入StoreLoad屏障

volatile使用场景
  1. volatile对单一赋值操作保证了原子性,例如volatile int a = 1,但是对于多步骤的运算如++i等就无法保证原子性,因此volatile时候修饰作为状态标志的变量
  2. 在读多写少的情况下,我们可以利用volatile保证读操作的实时可见性而无需加锁,在写操作时加锁即可。这样可以提高程序的并发性能。
【面试题】谈谈你对volatile的理解

首先volatile有三大特征,可见性、无原子性和禁止重排序。volatile通过控制主内存和线程本地内存刷新的时机和控制线程本地内存失效等机制来保证可见性。不保证原子性指的是多步骤的符合计算,volatile不提供原子性的支持。对于禁止重排序,volatile操作的前后会加入内存屏障,对指令排序进行严格约束,最底层通过C++嵌入汇编实现的。

volatile 写之前的操作禁止排序到 volatile 之后;
volatile 读之后的操作禁止排序到 volatile 之前;
volatile 写之后的 volatile 读,禁止重排序

CAS

使用硬件级别的指令保证原子操作,避免重量级锁的使用。是一种无锁并发算法。无锁算法是一种不考虑资源有效利用率,通过反复轮询代替阻塞。

CAS的缺点

循环时间长,开销大

CAS失败,会一直进行尝试。如果CAS长时间不成功,可能会给CPU带来很大的开销

ABA问题

使用AtomicStampedReference,通过版本号解决ABA问题

Atomic 原子类

基本类型原子类

AtomicInteger,AtomicBooleanAtomicLong

[工具] 使用CountDownLatch解决线程汇合问题。例如主线程开启多个线程进行计算,何时知道自己该获取结果?C++中可以使用thread.join方法。

public class AtomicDemo {
    private static final int size = 50;
    private static final AtomicInteger atomicInteger = new AtomicInteger();
    private static final CountDownLatch countDownLatch = new CountDownLatch(size);

    public static void atomicPlus() {
        atomicInteger.incrementAndGet();
    }
    public static void main(String[] args) throws InterruptedException {

        for (int i = 0; i < size; i++) {
            Thread thread = new Thread(() -> {
                for(int j = 0; j < 1000; j++) {
                    atomicPlus();
                }
                countDownLatch.countDown(); // 减一
            });
            thread.start();
        }
        // TimeUnit.SECONDS.sleep(2);
        countDownLatch.await(); // 等到countDownLatch计数为0时,从阻塞中唤醒
        System.out.println(atomicInteger.get());
    }
}

数组类型的原子类

AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

使用起来很简单,就是一个由多个原子类型构成的数组,使用起来和基本原子类型类似。

引用类型原子类

AtomicStampedReference通过版本号 解决ABA问题
AtomicMarkableReference将版本号简化为状态标记bool,true/false切换

对象属性修改类
AtomicIntegerFieldupdaterAtomicLongFieldupdaterAtomicReferenceFieldupdater

属性修改原子类

AtomicIntegerFieldUpdater

AtomicLongFieldUpdater

AtomicReferenceFieldUpdater

底层使用反射技术实现

[使用目的]:以一种线程安全的方式操作非线程安全对象的某些字段,实现更小粒度的原子操作,最大程度的提高性能。

[使用要求]:更新的对象属性必须使用public volatile修饰,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法newUpdate创建一个更新器,并且需要设置想要更新的类

原子操作增强类

DoubleAccumulator,一个或多个变量共同维护使用提供的函数更新的运行double值

DoubleAddr,一个或多个变量共同维持最初的零和double总和

LongAccumulater,一个或多个变量共同维护使用提供的函数更新的运行的long值

LongAddr,一个或多个变量,它们共同维持最初为零的long总和

[思想],高并发下对一个对象的操作可能会造成大量资源竞争,因此考虑将一个资源拆分为多个,操作时互不影响,最后计算结果时统一加锁后计算即可。

阿里开发手册:如果是JDK8,推荐使用LongAdder对象,它必AtomicLong性能更好,因为它减少了乐观锁重试次数

[面试题]

  1. 热点商品点赞计数器,点赞数加加统计,不要求实时精确
  2. 一个很大的list,里面都是int类型,如何实现加加,说说思路

LongAddr原子操作增强类常见API

void add(long x); // 将当前value值加x
void increment(); // 将当前value值加1
void decrement(); // 将当前value值减1
long sum(); // 返回当前值。需要注意的是,在没有并发更新value的情况下,sum返回的是精确值;如果存在并发的情况,sum返回调用时刻的精确值
void reset(); // 将value重置为0,只能在没有并发的环境下使用
long sumThenRest(); // 获取当前value值,然后重置为0

LongAddr只能计算加法,而LongAccumulater提供了自定义的操作

    public static void test04() {
        LongAccumulator la = new LongAccumulator(new LongBinaryOperator() {
            @Override
            public long applyAsLong(long left, long right) {
                return left * left;
            }
        }, 1); // 初始值
        la.accumulate(2); // 1 * 2 == 2
        la.accumulate(3); // 2 * 3 == 6
    }

ThreadLoacl

线程局部变量,线程局部存储

[面试题]

  1. ThreadLocal中的ThreadLocalMap的数据结构和关系?
  2. ThreadLocal的key是弱引用,为什么?
  3. ThreadLocal内存泄漏问题是什么,怎么解决
  4. ThreadLocal中为什么最后要加上remove方法

ThreadLocal基本概念

ThreadLocal提供线程局部变量。这些变量与正常变量不同,因为每一个线程在访问ThreadLocal实例的时候(通过get或者set方法)都有自己独立的副本。ThreadLocal实例通常是类的私有静态字段,使用它的目的是希望将状态例如ID信息与线程关联起来。ThreadLocal在一定程度上避免了线程安全问题。

[API]

T get(); // 返回当前线程的此线程局部变量副本中的值
protected T initialValue(); // 返回此线程局部变量的当前线程的“初始值”
void remove(); // 删除此线程局部变量的当前线程值
void set(T value); // 将此线程局部变量的当前线程副本设置为指定的值
static <S> ThreadLocal<S> withInitial(Supplier<? extend S> supplier); // 创建一个线程局部变量

remove方法

在线程池的场景下,线程经常会被复用,如果不清理自定义的ThreadLocal变量,可能会导致内存泄漏问题。一般使用try–catch–finally结构,在finally代码段中调用ThreadLocal.remove方法。

Thread,ThreadLocal和ThreadLoaclMap

Thread里面有一个ThreadLocalMap类型的成员变量,ThreadLocalMap类是ThreadLocal类中的静态内部类。

ThreadLocalMap实际上就是一个以ThreadLocal实例为key,任意对象为value的map。当我们为ThreadLocal变量赋值时,实际上就是以当前ThreadLocal实例为key,值为value向线程的ThreadLocalMap对象存放元素。

ThreadLocal内存泄漏问题

  1. ThreadLoacl在两个地方被保存,一个是用户new出来对象的地方,另一个是作为线程ThreadLoaclMap中的Entry的key,也就是被引用两次。用户new出来的ThreadLocal对象会使用强引用,而ThreadLocalMap中会使用弱引用保存ThreadLocal。这样,如果用户代码中ThreadLocal变量强引用被取消了,ThreadLocalMap中对应ThreadLocal的弱引用也会在合适时机被删除。同时让Entry的key变为NULL。
  2. 上面提到Entry的key变为NULL,因此在使用线程池的情况下,由于线程一直被复用,这样会导致Entry中value字段的值一直被强引用,进而无法回收,最终导致内存泄漏。所以我们需要手动调用ThreadLocal.remove方法来释放内存

实际上,ThreadLocal.ThreadLocalMap.expungeStaleEntry方法专门用来清楚value,而Entry的key为NULL,那么我们对这个entry进行get、set操作时就会自动清理value值,而我们remove直接将entry的key变为NULL,然后调用该方法进行清除。

ThreadLocal使用总结

最佳实践
  1. [强制]使用ThreadLocal.withInitial静态方法创建一个ThreadLocal变量,初始化该值,避免使用时出现空指针异常
  2. [建议]使用static关键字修饰ThreadLocal变量。ThreadLocal实现了线程数据隔离,不在于他本身而在于Thread内部的ThreadLocalMap对象,因此使用static修饰ThreadLocal变量可以减少空间占用(只被初始化一次)
  3. [强制]使用完ThreadLocal记得手动remove
总结
  1. ThreadLocal并不解决线程间共享数据的问题,他是每个线程私有的
  2. ThreadLocal适用于变量在线程间隔离且在方法间共享的场景
  3. ThreadLocal通过隐式的在不同线程内创建独立实例副本避免了实例的线程安全问题
  4. 每个线程持有一个只属于自己的Map并维护了ThreadLocal对象与具体实例的映射,该Map由于只被一个线程持有,所以没有线程安全问题
  5. ThreadLocalMap的Entry对ThreadLoacl的引用为弱引用,避免了ThreadLoacl对象可能无法被回收的问题
  6. 内部提供方法实现对key为NULL的Entry的value字段清除,这在调用set/get/remove时会触发

java内存布局

对象在堆中存储布局

HotSpot虚拟机里,对象在堆中内存的存储布局可以划分为三部分:对象头(Header),实例数据(Instance Data),对齐填充(Padding),保证8字节的倍数。

  1. 对象头

    • 对象标记Mark Word

      对象哈希码,GC标记,GC次数,同步锁标记,偏向锁持有者。

    • 类元信息(又称之为类型指针)

      存储指向该对象元数据klass的首地址,也就是用来判断对象的类型。因此多个相同类型的对象的类型指针其实指向的是同一个地址。

    • 对象头的大小

      64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共占用16个字节。也就是说,如果我们new一个Object类型对象,他没有实力数据,此时它也不需要padding,因此其总大小就是16字节。

  2. 实例数据

    存放类的属性(field)数据信息,包括父类的属性信息

  3. 对齐填充

    虚拟机要求对象的其实地址必须是8字节的整数倍

压缩指针

java虚拟机打印参数:java -XX:+PrintCommandLineFlags -version,会发现这个压缩指针选项(-XX:+UseCompressedClassPointers)默认开启,因此对象头中的类型指针会从八个字节压缩为四个字节

synchronized与锁升级

阿里巴巴开发手册

[强制] 高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构就不要用锁;能锁区块就不要锁整个方法体;能用对象锁就不要用类锁。

尽可能使加锁的代码块工作量尽可能少,避免在加锁的代码块中使用PRC方法。

锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁(CAS) --> 重量级锁

对象头中对象标记的后三位对应着锁的种类

无锁态:001
偏向锁:101
轻量级锁:000
重量级锁:010

Synchronized的缺陷

在Java5之前,只有Synchronized重量级锁,使用的是操作系统的互斥锁,由于Java的线程是映射到操作系统线程上的,因此阻塞/唤醒一个线程就需要操作系统的介入,这里就牵扯到用户态和内核态切换的问题,频繁的切换会浪费大量资源。如果用户代码简单,导致上锁开销比真正业务逻辑开销还大,就显得有些不合适了,因此后续引入了轻量级锁和偏向锁等。

对象头中锁的指向

偏向锁:MarkWord存储的是偏向线程的ID

轻量锁:MarkWord存储的是指向线程的栈中的Lock Record指针

重量锁:MarkWord存储的是堆中monitor的指针

偏向锁 BiasedLock

java15之后逐渐废弃使用

偏向锁一般用在单线程竞争中,比如一段同步代码块进入和退出时需要上锁和解锁,但是程序运行时发现绝大部分情况(甚至所有情况)都只有一个线程,此时则会触发偏向锁。对象头中的MarkWord存储偏向线程的ID,在进入同步代码块时直接比较线程ID,如果相同则直接获取锁,这样避免了用户态到内核态的切换的开销。因此偏向锁提高了一个线程执行同步代码块的效率。

偏向锁工作描述

加锁对象的状态:首先对象头的MarkWord的前54个bit记录了偏向线程的指针,同时最高的3bit内容是101。

  1. 当锁第一次被一个线程拥有时,就会记录下此线程的ID,代表着这个线程拥有这个偏向锁。
  2. 当一个线程来获取这把锁时,首先发现他是偏向锁,因此会去比较偏向锁的偏向线程和自己比较
    • 如果相同,代表偏向线程来获取这把偏向锁,跳过加锁过程而直接进入同步代码块
    • 如果不同,代表发生了锁竞争,锁已经不总是偏向于同一个线程了。这是新来的线程会尝试使用CAS来替换MarkWord里面的线程ID为新线程ID
      • 替换成功,正常执行,偏向锁不会升级,只是该变了偏向的线程
      • 替换失败,偏向锁需要升级为轻量级锁,才能保证线程之间的公平竞争
偏向锁的撤销

当有除了偏向锁之外的其他线程来竞争锁时,就不能再使用偏向锁了。其他线程的竞争方式是的不断自旋采用CAS方式来尝试更新锁对象的头对象。偏向锁撤销需要等到全局安全点(该时间点上没有字节码正在执行)。

锁竞争时出现的两种情况

  1. 第一个线程正在执行synchronized方法,但是他还没有执行完,其他线程过来抢夺,该偏向锁会被取消并出现锁升级(偏向锁–>轻量级锁)。此时轻量级锁仍由原来持有偏向锁的线程持有并且继续执行其同步代码块,而正在竞争的线程会进入自旋等待获得该轻量级锁。
  2. 第一个线程已经执行完毕了synchronized方法,此时其他线程来争夺,则会将对象头设置为无锁状态并撤销偏向锁,然后重新偏向。

轻量级锁

MarkWord前62位保存指向线程栈中Lock Record的指针,最后三位为000

使用场景:有线程来参与锁的竞争,但是获取锁的冲突时间极短,本质就是自旋锁CAS。也就是在线程交替执行的情景下提升性能。

自适应自旋锁

轻量级锁本质就是CAS,多个线程自旋争抢。如果达到某个自旋限制条件后,轻量级锁会升级为重量级锁。Java6之后,采用自适应的方式来判断自旋的限制:自旋的限制不是固定的,他受到同一个锁上次自旋的时间和锁线程的状态来决定,有点像卡尔曼滤波平滑处理。

偏向锁和轻量级锁的异同

同:其他线程争夺锁失败时,都会自旋尝试抢占锁。

异:轻量级锁每次退出同步代码块都需要释放锁,而偏向锁是在竞争发生时才释放锁。

重量级锁

Java中synchronized重量级锁,是基于进入和退出的Monitor对象实现的,也就是在编译时期将同步块的开始位置插入moniter enter指令,在结束位置插入moniter exit指令。

其他

锁升级后hashcode等信息去哪里了
  1. 无锁状态下,MarkWord中可以存储对象的哈希码值,当对象的hashCode()方法第一次被调用时,JVM会生成对应的身份哈希码值,并将该值存储到Mark Word中。
  2. 对于偏向锁,其他字段会覆盖哈希码的存放位置,因此当一个对象计算过哈希码后,就不可能(不应该)再进入偏向锁状态了,否则两次计算哈希码会导致计算不一致。
  3. 升级为轻量级锁后,JVM会在当前线程的栈帧中创建一个记录锁(Lock Record)空间,用于存储对象的MarkWord拷贝,该拷贝中可以包含对象的哈希码,所以轻量级锁可以和哈希码共存,同时也包括其他GC信息。当锁释放后对象进入无锁状态时,对象信息回写到对象头中。
  4. 升级为重量级锁后,MarkWord保存重量级锁指针,但是代表重量级锁的ObjectMonitor类中有字段记录非加锁状态下MarkWord,锁释放也会回写到对象头中,因此重量级锁也可以和哈希值共存。
各种锁的优缺点,synchronized锁升级和实现原理

JIT编译器对锁的优化

JIT,Just In Time Compiler,一般翻译为即时编译器

锁消除

JIT会自动消除没有意义的锁,例如对线程局部变量加锁?

锁粗化

将多个同步代码块合并,减少加锁释放锁的次数。这里和一般做法想违背,因为一般我们都是尽可能减少同步代码块的大小的。

AQS

Abstract Queued Synchronizer 抽象的队列同步器

使用用来实现锁或者其他同步器组件的公共基础部分的抽象实现,是重量级基础框架及整个JUC体系的基石,主要用于解决锁分配给“谁”的问题。简化来看,就是一个抽象的FIFO队列来完成线程获取资源的排队操作,并通过volatile的int类型的变量表示持有锁的状态。

为什么AQS这么重要

ReentrantLock/CountDownLatch/ReentrantReadWriteLock/Semaphore等等都是依赖AQS实现的。

锁和同步器的关系

锁:面向使用者,定义了程序员和锁交互的使用层API,隐藏了实现的细节

同步器:面向锁的实现者。JUC的作者Doug Lee,提出了统一规范并简化了锁的实现,将其抽象出来屏蔽了同步状态管理、同步队列的管理和维护、阻塞线程排队和通知、唤醒机制等。

AQS能做什么

加锁必然导致阻塞,有阻塞就需要排队,因此需要一个队列工具来管理这个排队队列,它提供阻塞等待唤醒机制保证锁的分配,因此出现了AQS。

AQS使用一个volatile的int类型成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,将要去抢占资源的线程封装成Node节点,通过CAS实现对状态的修改。

AQS架构

status

volatile int类型,当其为0时表示资源空闲,当其为1时表示资源被占用。

CLH队列

基本结构如上图所示

内部类Node

内部包装了一个线程,也就是每一个等待的线程会存储在Node中,然后进入阻塞队列里

// Node status bits, also used as argument and return values
static final int WAITING   = 1;          // must be 1
static final int CANCELLED = 0x80000000; // must be negative
static final int COND      = 2;          // in a condition wait

abstract static class Node {
        volatile Node prev;       // initially attached via casTail
        volatile Node next;       // visibly nonnull when signallable
        Thread waiter;            // visibly nonnull when enqueued
        volatile int status;      // written by owner, atomic bit ops by others 线程的等待状态

        // methods for atomic operations
        final boolean casPrev(Node c, Node v) {  // for cleanQueue
            return U.weakCompareAndSetReference(this, PREV, c, v);
        }
        final boolean casNext(Node c, Node v) {  // for cleanQueue
            return U.weakCompareAndSetReference(this, NEXT, c, v);
        }
        final int getAndUnsetStatus(int v) {     // for signalling
            return U.getAndBitwiseAndInt(this, STATUS, ~v);
        }
        final void setPrevRelaxed(Node p) {      // for off-queue assignment
            U.putReference(this, PREV, p);
        }
        final void setStatusRelaxed(int s) {     // for off-queue assignment
            U.putInt(this, STATUS, s);
        }
        final void clearStatus() {               // for reducing unneeded signals
            U.putIntOpaque(this, STATUS, 0);
        }

        private static final long STATUS
            = U.objectFieldOffset(Node.class, "status");
        private static final long NEXT
            = U.objectFieldOffset(Node.class, "next");
        private static final long PREV
            = U.objectFieldOffset(Node.class, "prev");
    }

从ReentrantLock源码看AQS

架构

ReentrantLock内部包含一个Sync类型的对象,Sync继承了类AbstractQueuedSynchronizer。而ReentrantLock内部还实现了静态类FairSyncNonFairSync,他们继承自Sync,而ReentrantLock的非公平锁和公平锁也是基于这两个类实现的。

公平锁和非公平锁

由于ReentrantLock基于AQS,所有必然有一个维护了队列保存等待的线程,这些线程在队列中是按照触发时间排序的(FIFO)。公平锁就是在一个线程被唤醒是还看看在自己前面有没有别的线程在等待,如果有则放弃争夺锁,这样体现了“公平”原则;而非公平锁,线程就不会考虑自己之前是否还有已经排队的线程而直接获取锁,直观来说就是一种插队的行为。 以上的行为发生在ReentrantLock的方法acquire中,而lock方法就是调用acquire方法。

执行流程

…看看源码,这里就不记录了

整个ReentrantLock加锁过程可以分为三个阶段:

  1. 尝试加锁
  2. 加锁失败,线程入队列
  3. 线程入队列后,进入阻塞状态,最后处理的主要逻辑是:在一个无限循环中不断尝试获取锁,如果获取失败则根据参数决定是否等待或者中断。在循环中,首先判断当前线程是否是第一个等待的线程,如果是,则尝试获取锁,如果获取成功则设置头节点并唤醒后续等待的线程;如果不是第一个等待的线程,则根据参数决定是否等待或者中断。

其他锁

[面试题]

  1. 你知道Java里有哪些锁?
  2. 读写锁的饥饿问题是什么?有没有比读写锁更快的锁?
  3. StampLock和ReentrantReadWriteLock是否了解,ReentrantReadWriteLock锁降级机制是否了解?

ReentrantWriteReadLock

读写锁定义&带来的问题

一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。

锁饥饿,读锁多写锁少,读锁一直不释放资源,最终导致写锁饥饿

锁降级,锁降级解决锁饥饿的问题

锁降级策略

ReentrantWriteReadLock锁降级,将写入锁降级为读锁,锁的严苛程度变强叫做锁升级,反之叫做锁降级。

锁降级的过程:如果一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获取读锁。这就是写锁的降级,降级成为了读锁。一般的使用次序是先获取写锁,然后获取读锁,再释放写锁。如果释放了写锁,那么就完全转化为读锁。但是如果先获取了读锁,除非释放该读锁,否则无法继续获取写锁。

ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.writeLock().lock();
// 处理一些写操作业务
rwLock.readLock().lock(); // 在写锁未释放前添加读锁
rwLock.writeLock().unlock();
// 处理一些读操作业务
rwLock.readLock().unlock();

这样的设计可以在最大化提高读写锁性能的同时保证资源不被其他写入锁抢占。

StampedLock

邮戳锁、版本锁、票据锁

stamp,long类型变量,代表当前锁的状态。当stamp返回零时,表示线程获取锁失败;释放锁或者转换锁的时候,都要传入最初获取的stamp值。

针对锁饥饿问题的处理
  1. 可以使用公平锁,公平的策略一定程度上避免了锁饥饿的问题,但是公平策略是通过牺牲系统吞吐量而实现的。
  2. 使用StampedLock,它利用乐观锁的思想,实现了锁升级的操作。
StampedLock特点
  • 所有获取锁的方法,都会返回一个邮戳stamp,stamp为零表示获取锁失败,其余的都表示成功;
  • 所有释放锁的方法,都需要一个邮戳stamp,stamp必须是和成功获取锁时获取得到的stamp一致;
  • StampedLock是不可重入的,如果一个线程已经持有了写锁,在去获取读/写锁会导致死锁。
  • StampedLock有三种访问模式
    1. Reading(悲观读模式),与ReentrantReadWriteLock类似
    2. Writing(写模式),与ReentrantReadWriteLock类似
    3. Optimistic reading(乐观读模式),使用无锁机制,类似数据库,支持读写并发。乐观的认为读取时没人修改,加入被修改在升级为悲观读模式。
乐观方法
public boolean validate(long stamp);
// 通过stamp来判断自己加锁过程中有没有别的写操作影响,返回true表示没有被修改,返回false表示被修改

public long tryOptimsticRead();
// 返回稍后可以验证的stamp,如果是完全锁定返回零
// 只有该方法创建的读锁可以插入写锁
// 该方法没有对应的unlock?
// 演示
        StampedLock lock = new StampedLock();
        long stamp = lock.tryOptimisticRead();
            // 处理一些业务,可能比较耗时
            // .......
            // 业务处理完毕,验证一下这段时间内读的数据是否被修改
            if (!lock.validate(stamp)) {
                // 如果被修改 并且还需读数据的话
                stamp = lock.readLock(); // 乐观读升级为悲观读
                try {
                    // 其他操作处理
                } finally {
                    lock.unlockRead(stamp); // 释放悲观读的锁
                }
            }

StampedLock.tryOptimisticRead()方法的设计目的就是为了在不需要显式解锁的情况下提供一种无锁的读取方式,从而提高并发性能。当乐观读锁失败时,可以通过其他方式(如悲观读锁)来保证数据的一致性。

缺点
  1. 不支持重入
  2. StampedLock的悲观读锁和悲观写锁都不支持条件变量
  3. StampedLock与中断(interrupt)不兼容,一起使用会出问题。
  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值