Q&A-04 多线程&分布式

参考链接:
CS-Notes/notes/Java 并发.md
Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)

Java实现多线程有哪几种方式

Java 多线程实现方式主要有四种:

  1. 继承Thread类
  2. 实现Runnable接口
  3. 实现Callable接口通过FutureTask包装器来创建Thread线程
  4. 使用ExecutorService、Callable、Future实现有返回结果的多线程

其中前两种方式线程执行完后都没有返回值,后两种是带返回值的。

链接:https://www.cnblogs.com/felixzh/p/6036074.html

对callable 和 future的理解

callable 分别使用 FutureTask 和 线程池完成回调:

callable 分别使用 FutureTask 和 线程池完成回调

链接:https://www.jianshu.com/p/810fe1cbfb1a

线程池

为什么禁止使用Executors来创建线程池

Executors创建出来的线程池使用的全都是无界队列,而使用无界队列会带来很多弊端,最重要的就是,它可以无限保存任务,因此很有可能造成OOM异常。同时在某些类型的线程池里面,使用无界队列还会导致maxinumPoolSize、keepAliveTime、handler等参数失效。因此目前在大厂的开发规范中会强调禁止使用Executors来创建线程池。

参考链接:https://juejin.cn/post/6844904002363080717

Executors包有哪几种类型的线程?

  1. newFixedThreadPool
  2. newSingleThreadExecutor
  3. newCachedThreadPool
  4. newScheduledThreadPool

newFixedThreadPool

public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

顾名思义,就是创建线程数量固定的线程池,线程池的corePoolSize和maximumPoolSize大小一样,并且keepAliveTime为0,传入的队列LinkedBlockingQueue为无界队列。在说ThreadPoolExecutor的时候也说过,传入一个无界队列,maximumPoolSize参数是不起作用的。

newSingleThreadExecutor

public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

从代码中也能看得出来,corePoolSize和maximumPoolSize都是1,keepAliveTime是0L, 传入的队列是无界队列。线程池中永远只要一个线程在工作。

newCachedThreadPool

public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

可缓存线程池,说道缓存一般离不开过期时间,该线程池也是,corePoolSize设置为0,maximumPoolSize设置为int最大值,不同的是,线程池传入的队列是SynchronousQueue,一个同步队列,该队列没有任何容量,每次插入新数据,必须等待消费完成。当有新任务到达时,线程池没有线程则创建线程处理,处理完成后该线程缓存60秒,过期后回收,线程过期前有新任务到达时,则使用缓存的线程来处理。

newScheduledThreadPool

public static ScheduledExecutorService newScheduledThreadPool(
            int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
    }

这个线程池使用了ScheduledThreadPoolExecutor,该线程池继承自ThreadPoolExecutor, 执行任务的时候可以指定延迟多少时间执行,或者周期性执行。

public ScheduledThreadPoolExecutor(int corePoolSize) {
    // 核心线程数为传入的线程数,即1
    // 最大线程数为Integer.MAX_VALUE
    // 使用的阻塞队列是DelayedWorkQueue,这是一个无界队列
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

可以发现,ScheduledThreadPoolExecutor的最大线程数为Integer.MAX_VALUE,使用的是DelayedWorkQueue队列,这是一个无界队列。由于是无界队列,那么就会是最大线程数maximumPoolSize这个参数无效,所以即使将最大线程数为Integer.MAX_VALUE也没有什么用处。

DelayedWorkQueue又是一个什么队列呢?它是ScheduledThreadPoolExecutor定义的一个静态内部类,它的本质就是一个延时队列,其功能和DelayQueue类似。在DelayQueue中,包含了一个PriorityQueue(具有优先级的队列)类型的属性,而DelayedWorkQueue是DelayQueue和PriorityQueue的结合体,它会将提交到线程池的任务封装成一个RunnableScheduledFuture对象,然后将这些对象按照一定规则排好序。

RunnableScheduledFuture是ScheduledThreadPoolExecutor的一个私有内部类,继承了FutureTask。它包含三个非常重要的属性:

  1. sequenceNumber,任务被添加到线程池时的序号
  2. time,任务在哪个时间点执行
  3. period,任务执行的周期

DelayedWorkQueue会将队列中所有的RunnableScheduledFuture按照每个RunnableScheduledFuture的time按照从小到大排序,时间最小的应该最先被执行,所以排在最前面,当出现多个任务的时间相同时,就按照sequenceNumber这个序号从小到大排序,这样线程池中就能定时的执行这些任务了。

ScheduledThreadPoolExecutor执行任务的详细步骤如下:

  1. 从DelayedWorkQueue队列中通过peek()获取第一个任务,判断任务的执行时间是否小于当前时间,如果不小于,则说明还没到任务的执行时间,就让线程再继续等待一段时间;如果小于或者等于,就执行下面的流程。
  2. 通过poll()操作从队列中取出第一个任务,如果队列中还有任务,就唤醒处于等待队列中的线程,通知它们也来尝试获取任务。
  3. 当前线程执行取出的任务。
  4. 执行完任务后,修改RunnableScheduledFuture任务的time属性的值,将其设置为下次将要在被执行的时间点,然后将任务放回到任务队列中。

参考链接:

实现原理

  1. 先判断线程池中线程的数量是否超过核心线程数,如果没有超过核心线程数,就创建新的线程去执行任务;如果超过了核心线程数,就进入到下面流程。
  2. 判断任务队列是否已经满了,如果没有满,就将任务添加到任务队列中;如果已经满了,就进入到下面的流程。
  3. 再判断如果创建一个线程后,线程数是否会超过最大线程数,如果不会超过最大线程数,就创建一个新的线程来执行任务;如果会,则进入到下面的流程。
  4. 执行拒绝策略。
image.png

ThreadPoolExecutor的参数有哪些

ThreadPoolExecutor 类中提供的四个构造⽅法。我们来看最⻓的那个,其余三个都是在这个构造⽅法的基础上产⽣(其他⼏个构造⽅法说⽩点都是给定某些默认参数的构造⽅法⽐如默认制定拒绝策略是什么)。

/**
 * ⽤给定的初始参数创建⼀个新的ThreadPoolExecutor。
 */
public ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize,
                            long keepAliveTime,
                            TimeUnit unit,
                            BlockingQueue<Runnable> workQueue,
                            ThreadFactory threadFactory,
                            RejectedExecutionHandler handler) {
     if (corePoolSize < 0 ||
         maximumPoolSize äã 0 ||
         maximumPoolSize < corePoolSize ||
         keepAliveTime < 0)
         throw new IllegalArgumentException();
     if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
     this.corePoolSize = corePoolSize;
     this.maximumPoolSize = maximumPoolSize;
     this.workQueue = workQueue;
     this.keepAliveTime = unit.toNanos(keepAliveTime);
     this.threadFactory = threadFactory;
     this.handler = handler;
}

ThreadPoolExecutor 3 个最重要的参数:

  1. corePoolSize : 核⼼线程数线程数。
  2. maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运⾏的线程数量变为最⼤线程数。
  3. workQueue : 当新任务来的时候会先判断当前运⾏的线程数量是否达到核⼼线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor 其他常⻅参数:

  1. keepAliveTime :当线程池中的线程数量⼤于 corePoolSize 的时候,如果这时没有新的任务提交,核⼼线程外的线程不会⽴即销毁,⽽是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁;
  2. unit : keepAliveTime 参数的时间单位。
  3. threadFactory :executor 创建新线程的时候会⽤到。
  4. handler :饱和策略。

ThreadFactory 线程工厂

新线程使用ThreadFactory创建。 如果未另行指定,则使用Executors.defaultThreadFactory默认工厂,使其全部位于同一个ThreadGroup中,并且具有相同的NORM_PRIORITY优先级和非守护进程状态。
通过提供不同的ThreadFactory,您可以更改线程的名称,线程组,优先级,守护进程状态等。如果ThreadCactory在通过从newThread返回null询问时未能创建线程,则执行程序将继续,但可能无法执行任何任务。
线程应该有modifyThread权限。 如果工作线程或使用该池的其他线程不具备此权限,则服务可能会降级:配置更改可能无法及时生效,并且关闭池可能会保持可终止但尚未完成的状态。

队列策略

  1. Direct handoffs 直接握手队列

Direct handoffs 的一个很好的默认选择是 SynchronousQueue,它将任务交给线程而不需要保留。这里,如果没有线程立即可用来运行它,那么排队任务的尝试将失败,因此将构建新的线程。
此策略在处理可能具有内部依赖关系的请求集时避免锁定。Direct handoffs 通常需要无限制的maximumPoolSizes来避免拒绝新提交的任务。 但得注意,当任务持续以平均提交速度大余平均处理速度时,会导致线程数量会无限增长问题。

  1. Unbounded queues 无界队列

当所有corePoolSize线程繁忙时,使用无界队列(例如,没有预定义容量的LinkedBlockingQueue)将导致新任务在队列中等待,从而导致maximumPoolSize的值没有任何作用。当每个任务互不影响,完全独立于其他任务时,这可能是合适的; 例如,在网页服务器中, 这种队列方式可以用于平滑瞬时大量请求。但得注意,当任务持续以平均提交速度大余平均处理速度时,会导致队列无限增长问题。

  1. Bounded queues 有界队列

一个有界的队列(例如,一个ArrayBlockingQueue)和有限的maximumPoolSizes配置有助于防止资源耗尽,但是难以控制。

队列大小和maximumPoolSizes需要 相互权衡:

  • 使用大队列和较小的maximumPoolSizes可以最大限度地减少CPU使用率,操作系统资源和上下文切换开销,但会导致人为的低吞吐量。如果任务经常被阻塞(比如I/O限制),那么系统可以调度比我们允许的更多的线程。
  • 使用小队列通常需要较大的maximumPoolSizes,这会使CPU更繁忙,但可能会遇到不可接受的调度开销,这也会降低吞吐量。
    这里主要为了说明有界队列大小和maximumPoolSizes的大小控制,若何降低资源消耗的同时,提高吞吐量。

饱和策略

如果当前同时运⾏的线程数量达到最⼤线程数量并且队列也已经被放满了时, ThreadPoolTaskExecutor 定义⼀些策略:

  • ThreadPoolExecutor.AbortPolicy :抛出RejectedExecutionException 来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy :让调⽤execute方法的线程执行任务。
  • ThreadPoolExecutor.DiscardPolicy : 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。

默认使⽤的是 ThreadPoolExecutor.AbortPolicy。

在线程池创建一个线程的过程

  1. 获取线程池状态,线程池状态正确,执行2 线程池状态不正确返回false。
  2. 判断线程池中数目和传入的要求是否一致。不一致返回false,一致执行3
  3. CAS增加线程池的线程数(有个记录线程数的变量)。成功执行4,不成功执行1
  4. 获取线程池的锁,创建一个worker(也是线程)。
  5. 判断线程池的状态,状态正确。执行6,不正确返回false
    将新添加的work放入存放线程的hashset中。启动线程成功!返回true,启动失败执行7
  6. 将刚才添加的线程从hashset中移除,然后返回false。

使用ThreadPoolExecutor

  1. 可以使用execute(Runnable task)方法向线程池中提交一个任务,没有返回值。

  2. 也可以使用submit()方法向线程池中添加任务,有返回值,返回值对象是Future。submit()方法有三个重载的方法:

  • submit(Runnable task),该方法虽然返回值对象是Future,但是使用Future.get()获取结果是null。
  • submit(Runnable task,T result),方法的返回值对象是Future,通过Future.get()获取具体的返回值时,结果与方法的第二个参数result相等。
  • submit(Callable task),该方法的参数是一个Callable类型的对象,方法有返回值。

线程池中线程的个数

线程池中线程的个数,在《java虚拟机并发编程》中会定义如下: 线程数=cpu可用核心数/(1-阻塞系数),其中阻塞系数的取值在[0,1]之间。 计算密集型任务的阻塞系数为0,而IO密集型任务的阻塞系数则接近1。

Runtime.getRuntime().availableProcessors();

返回Java虚拟机可用的处理器数,永远不会小于一个。
在特定的虚拟机调用期间,此值可能会更改。 因此,对可用处理器数量敏感的应用程序应偶尔轮询此属性并适当调整其资源使用情况。

参考链接:https://blog.csdn.net/u013276277/article/details/82630336](https://blog.csdn.net/u013276277/article/details/82630336)

参考链接:

volitile关键字

volatile 关键字的主要作用

  1. 可⻅性
    即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 有序性,并进一步可以防⽌指令重排序

但是volatile 不能确保原子性。

volatile 关键字的原理

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令
lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 写操作:强制将对CPU高速缓存的修改操作立即写入主存,并使其他CPU中对应的缓存行无效。

链接:

synchronized关键字

synchronized关键字的用法

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁;
  2. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁;
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized关键字的原理

同步代码块:是通过 monitorenter 和 monitorexit 指令获取线程的执行权;
同步方法:通过加 ACC_SYNCHRONIZED 标识实现线程的执行权的控制。

synchronized关键字的优缺点

优点:
可见性,有序性,原子性。
synchronized关键字在JavaSE1.6之后进⾏了主要包括为了减少获得锁和释放锁带来的性能消耗⽽引⼊的偏向锁和轻量级锁以及其它各种优化之后执⾏效率有了显著提升。

缺点:
多线程访问volatile关键字不会发⽣阻塞,⽽synchronized关键字可能会发⽣阻塞。(即使用synchronized,当多个线程尝试获取锁时,未获取到锁的线程会不断的尝试获取锁,而不会发生中断,这样会造成性能消耗。)

synchronized的有序性保证呢?
看到这里可能有朋友会问了,说到底上面问题还是个有序性的问题,不是说synchronized是可以保证有序性的么,这里为什么就不行了呢?
首先,可以明确的一点是:synchronized是无法禁止指令重排和处理器优化的。那么他是如何保证的有序性呢?
这就要再把有序性的概念扩展一下了。Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。
以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明并没有详细的解释。这里我简单扩展一下,这其实和as-if-serial语义有关。
as-if-serial语义的意思指:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
这里不对as-if-serial语义详细展开了,简单说就是,as-if-serial语义保证了单线程中,不管指令怎么重排,最终的执行结果是不能被改变的。
那么,我们回到刚刚那个双重校验锁的例子,站在单线程的角度,也就是只看Thread1的话,因为编译器会遵守as-if-serial语义,所以这种优化不会有任何问题,对于这个线程的执行结果也不会有任何影响。
但是,Thread1内部的指令重排却对Thread2产生了影响。
那么,我们可以说,synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略。

DCL:双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;
    
    private Singleton() {
    }
    
       // 方法的 synchronized 可加可不加
    public static Singleton getUniqueInstance() {
        //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance对象的初始化不是原子操作,可能发生指令重排,导致一个线程可能拿到一个不完整的uniqueInstance对象,当尝试使用这个对象的时候就极有可能发生空指针异常。
对uniqueInstance使用volatile约束,可以保证他的初始化过程不会被指令重排。

链接:

synchronized关键字的作用是什么? - Java面试题 (javanav.com)

Atomic、volatile、synchronized、ThreadLocal优缺点比较_wang123459的博客-CSDN博客_threadlocal优缺点

既然synchronized是"万能"的,为什么还需要volatile呢?

volatile和synchronized的区别

  • volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

参考链接:https://blog.csdn.net/suifeng3051/article/details/52611233

Lock

源码:

public interface Lock {

    // 获取锁
    void lock();

    // Acquires the lock unless the current thread is interrupted.
    void lockInterruptibly() throws InterruptedException;

    // Acquires the lock only if it is free at the time of invocation.
    boolean tryLock();

    // Acquires the lock if it is free within the given waiting time and the
    // current thread has not been interrupted.
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    // 释放锁
    void unlock();

    Condition newCondition();
}

Lock接口实现类/子接口

实现类:
AQS(AbstractQueuedSynchronizer)
ReentrantLock
ReentrantReadWriteLock
CountDownLatch
Semphore

子接口:
ReadWriteLock (子类:ReentrantReadWriteLock)

6.2 Lock和synchronized的区别

(1)原始构成

sychronized是关键字,属于JVM层面的, monitorenter、monitorexit(底层是通过monitor对象来完成的,其实wait/notify方法也依赖于monitor对象,只有在同步块或者同步方法中才能调用wait/notify等方法);
Lock是JDK实现的,属于具体类(java.util.concurrent.locks.lock)是api层面的锁。

(2)使用方法

synchronized不需要用户手动去释放锁,当synchronized代码执行完成后,系统会自动让线程释放对锁的占用;
ReentrantLock则需要用户手动去释放锁,若没有主动释放锁,就有可能导致出现死锁现象。需要lock()、unlock()方法配合try/finally语句块来完成。

(3)等待是否可中断

synchronized不可中断,除非抛出异常或者正常运行完成;
ReentrantLock可中断: 设置超时方法tryLock(long timeout, TimeUnit unit);lockInterruptibly()放代码块中,调用interrupt()方法可中断

(4)加锁是否公平

synchronized非公平锁
ReentrantLock两者都可以,默认是非公平锁,构造方法可以传入boolean值,传入的值为true表示公平锁,传入的值为false表示非公平锁。

(5)锁绑定多个条件Condition

synchronized不能绑定多个条件
ReentrantLock用来实现分组需要唤醒的线程们,可以精确唤醒,而不像synchronized那样随便唤醒一个线程或者全部线程。

锁的对象(?)

synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。

Lock可以提高多个线程进行读操作的效率(通过ReadWriteLock?)。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

https://blog.csdn.net/lixinkuan328/article/details/94426872

ReentrantLock 与 synchronized 的区别

  1. 底层实现

synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),对象只有在同步块或同步方法中才能调用wait/notify方法。
ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。

synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁。
ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

  1. 是否需要手动释放

synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用;
ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。

  1. 是否可中断

synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成;
ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

  1. 是否可以知道是否获得锁

通过Lock可以知道有没有成功获取锁;
而synchronized却无法办到。

  1. 锁的对象

synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;
ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。

  1. 是否公平锁

synchronized为非公平锁。
ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。

  1. 锁是否可绑定条件Condition

synchronized不能绑定;
ReentrantLock通过绑定Condition结合await()/singal()方法实现线程的精确唤醒,而不是像synchronized通过Object类的wait()/notify()/notifyAll()方法要么随机唤醒一个线程要么唤醒全部线程。

Lock 实现原理

AQS 和 CAS

链接:
Java的Lock实现类介绍_K-Darker的专栏-CSDN博客_lock实现类

Java笔试面试知识集合之Lock

面试官:谈谈synchronized与ReentrantLock的区别?

不可不说的Java“锁”事

一文带你理解 Java 中 Lock 的实现原理

可重入锁

可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。
作用:防止在同一线程中多次获取锁而导致死锁发生。

Synchronized和ReentrantLock都是可重入锁。

链接:
可重入锁 - 简书 (jianshu.com)

乐观锁,悲观锁,CAS

乐观锁
总是假设最好的情况,每次去读数据的时候都认为别人不会修改,所以不会上锁, 但是在更新的时候会判断一下在此期间有没有其他线程更新该数据, 可以使用版本号机制和CAS算法实现。 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。 在Java中java.util.concurrent.atomic包下面的原子变量类就是基于CAS实现的乐观锁。

悲观锁
总是假设最坏的情况,每次去读数据的时候都认为别人会修改,所以每次在读数据的时候都会上锁, 这样别人想读取数据就会阻塞直到它获取锁 (共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。 传统的关系型数据库里边就用到了很多悲观锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。 Java中synchronized就是悲观锁思想的实现。

使用场景

  • 乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。
  • 悲观锁适用于读比较少的情况下(多写场景),如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

CAS的缺点及解决方法

  1. ABA问题
    如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
    J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题, 它可以通过控制变量值的版本来保证 CAS 的正确性。 也可以通过保证单向递增或递减来解决这个问题。
    大部分情况下 ABA 问题不会影响程序并发的正确性, 如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
image.png
  1. 自旋时间长开销大
    自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用, 第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源, 延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。 第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation) 而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

  2. 只能保证一个共享变量的原子操作
    CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。 但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性, 可以把多个变量封装成对象里来进行 CAS 操作. 所以我们可以使用锁或者利用AtomicReference类把多个共享变量封装成一个共享变量来操作。

CAS与synchronized的使用情景

  • 对于资源竞争较少(线程冲突较轻)的情况, 使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源; 而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  • 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大, 从而浪费更多的CPU资源,效率低于synchronized。

链接:
一篇文章带你解析,乐观锁与悲观锁的优缺点_Java总社区-CSDN博客_mysql悲观锁和乐观优缺点

ABC三个线程如何保证顺序执行

链接:
ABC三个线程如何保证顺序执行 - 简书 (jianshu.com)
阿里面试真题:ABC三个线程如何保证顺序执行?_chenpeng19910926的专栏-CSDN博客_abc三个线程如何保证顺序执行
如何确保三个线程顺序执行 - 简书 (jianshu.com)

线程的状态都有哪些

截图自javaguide面试突击版:

1
2
3
4

截图自CS-notes:

image.png
image.png

sleep和wait的区别

  1. sleep() 是 Thread 类的静态本地方法;wait() 是Object类的成员本地方法
  2. sleep() 方法可以在任何地方使用;wait() 方法则只能在同步方法或同步代码块中使用,否则抛出异常Exception in thread "Thread-0" java.lang.IllegalMonitorStateException
  3. sleep() 会休眠当前线程指定时间,释放 CPU 资源,不释放对象锁,休眠时间到自动苏醒继续执行;wait() 方法放弃持有的对象锁,进入等待队列,当该对象被调用 notify() / notifyAll() 方法后才有机会竞争获取对象锁,进入运行状态
  4. JDK1.8,sleep() 和 wait() 均需要捕获 InterruptedException 异常

链接:
sleep() 和 wait() 有什么区别_ConstXiong-CSDN博客
sleep( ) 和 wait( ) 的这 5 个区别,你知道几个? - 知乎 (zhihu.com)

notify和notifyall的区别

先说两个概念:锁池和等待池

  • 锁池:假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。
  • 等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁后,进入到了该对象的等待池中

Reference:java中的锁池和等待池

然后再来说notify和notifyAll的区别

  • 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁
  • 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争
  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

Reference:线程间协作:wait、notify、notifyAll

综上,所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,notifyAll调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify只会唤醒一个线程。

有了这些理论基础,后面的notify可能会导致死锁,而notifyAll则不会的例子也就好解释了。

链接:
java中的notify和notifyAll有什么区别?

image.png

ThreadLocal的了解,实现原理

让每⼀个线程都有⾃⼰的专属变量。

在每个线程Thread内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,这个threadLocals就是用来存储实际的变量副本的,键值为当前ThreadLocal变量,value为变量副本。

初始时,在Thread里面,ThreadLocalMap为空,当通过ThreadLocal变量调用get()方法或者set()方法,就会对Thread类中的ThreadLocalMap进行初始化,并且以当前ThreadLocal变量为键值,以ThreadLocal要保存的副本变量为value,存到ThreadLocalMap。然后在当前线程里面,如果要使用副本变量,就可以通过get方法在ThreadLocalMap里面查找。

一个Thread中只有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,一个threadLocals中可以有多个键值对,即一个Thread可以依附有多个ThreadLocal对象。

ThreadLocal变量的存在周期:存储在ThreadLocal中的对象将一直附在该线程,直到显式删除为止。

ThreadLocal的应用场景
在Java的多线程编程中,为保证多个线程对共享变量的安全访问,通常会使用synchronized来保证同一时刻只有一个线程对共享变量进行操作。这种情况下可以将类变量放到ThreadLocal类型的对象中,使变量在每个线程中都有独立拷贝,不会出现一个线程读取变量时而被另一个线程修改的现象。最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。在下面会例举几个场景。

链接:
ThreadLocal作用、场景、原理 - 简书 (jianshu.com)
ThreadLocal两个简单示例_时光如白驹过隙的博客-CSDN博客
深入理解ThreadLocal

多线程简单示例

廖雪峰-使用wait和notify

JUC 集合

ConcurrentLinkedQueue

锁优化

偏向锁、轻量级锁

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。

锁的状态保存在对象的头文件中,以32位的JDK为例:

image.png

偏向锁:
先假定同一时间只有一个线程用到锁,

轻量级锁:
CAS,自旋

重量级锁:
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。

image.png

参考链接:https://www.cnblogs.com/paddix/p/5405678.html

锁消除、锁粗化、适应性自旋

  1. 锁消除:
    对于被检测出不可能存在竞争的共享数据的锁进行消除。
    锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

  2. 锁粗化:
    将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。

  3. 适应性自旋:
    当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。
    在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

参考链接:https://www.cnblogs.com/paddix/p/5405678.html

两次start同一个线程会怎么样?

Java的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。

阻塞队列(Blocking Queue)

Java中阻塞队列的不同实现:

  1. ArrayBlockingQueue
    基于环形数组实现,入队和出队都会通过ReentrantLock来控制同步效果,通过两个Condition来控制线程之间的通信效果。

  2. LinkedBlockingQueue
    基于单向链表实现。
    这种阻塞队列含有链表的特性,那就是无界。但是实际上LinkedBlockingQueue是有界队列,默认大小是Integer的最大值,而也可以通过构造方法传入固定的capacity大小设置。
    两个ReentrantLock,入队锁和出队锁,所以入队和出队的时候不会有竞争锁的关系。

  3. DelayQueue
    延迟队列,顾名思义就是只有当元素达到指定的时间后才可以从队列中取出。
    DelayQueue主要也是通过ReentrantLock+Condition来保证线程安全,而内部还采用了ProrityQueue来保证队列的优先级,实际就是按延时的时间来进行排序,延迟时间最短的排在队列的头部,所以每次从头部获取的元素都是最先会过期的数据。

  4. PriorityBlockingQueue
    有优先级的阻塞队列,底层也是通过数组实现,默认初始容量为11,容量不够会自动扩容,扩容的最大值为Integer的最大值-8(有些虚拟机再实现数组头部存储内容所预留的空间),所以基本上可以认为是无界阻塞队列。
    扩容时的线程安全通过ReentrantLock+CAS+volatine实现。
    用法基本上和ArrayBlockingQueue差不多,只不过PriorityBlockingQueue相当于是无界,另外最重要的一点是它是有优先级的,既然有优先级就涉及到排序,PriorityBlockingQueue默认采用Comparator,或者存储的元素有自定义的比较器。

  5. SynchronousQueue
    SynchonousQueue是比较特殊的阻塞队列,特殊之处就是这个叫队列的队列没有容量,又或者说容量为0,所以一旦有元素插入此队列,由于没有容量,就必须被阻塞直到元素被取出。
    所以SynchronousQueue更像是一个通道,一端发数据,一端消费数据,数据不可以被堆积,发送方或消费方处理不过来或者是不处理都会导致阻塞

参考链接:https://www.cnblogs.com/jackion5/p/11173721.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容