Java 多线程学习总结和笔记

什么是线程和进程?线程和进程的关系?区别以及优缺点?

进程:是计算机中程序关于某数据集合中的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据以及某组织形式的描述,进程是程序的实体。
线程: 有事被称为轻量级进程,是程序执行流的最小单元。线程是程序中的一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派CPU的基本单位。在单个程序运行多 个线程完成不同的工作,称为多线程。
进程和线程的关系:1.一个程序中至少有一个进程,一个进程中至少有一个线程;2. 线程的划分尺度小于进程(占有资源),使得多线程程序的并发性高;3. 进程运行过程中拥有独立的内存空间,而线程之间共享内存,从而极大的提高了程序的运行效率;4.线程不能独立运行,必须存在于进程中。 优缺点:线程执行开销小,但不利于资源的管理和保护;进程正相反,开销大,但是利于资源的管理和保护。

线程的状态

线程分为5个状态:
  1. NEW 即新建状态,就是线程被创建而未启动的状态。
    线程创建的三种方法:
    1. 继承Thread类
    2. 实现Runnable接口。
    3. 实现Callable接口。Callable不同于其他两种的方式在于,calable的任务可以有返回值,而其他两种需要借助共享变量,callable可以抛出异常,而其他两种需要设置一个默认的异常处理器才可以抛异常。
  2. RUNABLLE 是线程启动,但是被cpu调用之前的就绪状态。
  3. RUNNING 是线程运行状态,是run()正在执行时的线程状态。线程可能由于某些因素退出运行状态,例如时间、异常、锁、调度等
  4. BLOCKED 即阻塞状态,进入此状态的有以下三种情况.
    1. 同步阻塞:锁被其他线程占用。
    2. 主动阻塞:调用Thread的某些方法,主动让出CPU执行权,比如sleep()、join()等等。
    3. 等待阻塞:执行了wait().
  5. DEAD 即终止状态,是run()运行结束 或因异常退出的状态,此状态不可逆转。

为什么使用多线程?

在并发情况下:
避免阻塞,如果一个线程因为I/O等原因阻塞,此时CPU就处于空闲状态。为了避免CPU资源的浪费和提升性能,可以创建多个来线程执行其他任务。
在并行情况下:
利用cpu多核心的特点,可以将一个任务拆分为多个任务,分配给多个线程同时执行

多线程的速度提升比例 = 1/[(1-P)+(P/N)] 其中P是可并行任务的比例,N是CPU的核心数量

为什么不使用多进程呢?

不同的进程内资源不是共享的,在并发执行中,每次切换进程就需要切换上下文,浪费极大的系统资源。假如任务是按照进程来分配的,在并发中,每次任务的切换都需要切换进程,就要切换上下文,然后才能执行。如果按照线程来分配,每次切换线程时,使用的共享资源一样,不需要切换上下文。相应快速。
例子

组代表进程,成员代表线程。假如公司只有一台电脑(并发 ),A组成员习惯用Linux,B组成员习惯用Windows,如果老板让A组完成任务,那么A组首先要装个Linux系统才能开始任务。做完后,老板让B组做个任务,B组又把电脑装成windows系统才开始完成任务。每次切换组的时候都要先设置电脑的系统才能工作,设置电脑的系统这段时间就是极大的浪费。现在老板设置了C组,C组有成员 a,b,c,d四个人都只用Linux系统,老板把任务分配给C组做,abcd四个人轮流使电脑工作,但是都是用到Linux系统,省下了装系统的时间。

并行和并发

并行和并发的区别在于进程是否同时执行。以KTV唱歌为例,并行指有多少人可以使用话筒同时唱歌;并发指同一个话筒被多个人使用。
并行时,多个线程可以在每一个时间段同时执行。并发,多个线程竞争执行,就是每个线程执行一个时间段,然后换一个线程执行一个时间段。

多线程带来的问题:

  1. 线程安全问题 线程安全问题指的是在某以现场从开始访问到结束访问某一数据期间,该数据被其他线程所修改,那么对于当前线程而言,该线程就发生了线程安全问题,表现为数据丢失,数据不一致等。解决方法: 尽量不是用共享变量,将不必要的共享变量变成局部变量来使用;使用synchronized关键字同步代码块,或者是用Lock为操作加锁;使用ThreadLocal为每一个线程建立一个变量的副本,各个线程间独立操作,互不影响。
  2. 性能问题 线程的生命周期开销是非常大的,一个线程的创建到消回都会占用大量的内存。同时如果不合理的创建了多个线程,cpu的处理器数量小于线程数量,那么将会有很多线程被闲置,限制的线程将会占用大量内存,为垃圾回收带来很大压力,同时cpu在分配线程时还会消耗其性能。解决方法:使用线程池
  3. 活跃性问题 死锁、饥饿、活锁、阻塞 解决方法: 减少锁持有时间,读写分离,减小锁的粒度,锁分离,锁粗化等方式来优化锁的性能。

什么是上下文切换

CPU通过时间片段的算法来循环执行线程任务,而循环执行即每个线程允许运行的时间后的切换,而这种循环的切换使各个程序从表面上看是同时进行的。而切换时会保存之前的线程任务状态,当切换到该线程任务的时候,会重新加载该线程的任务状态。而这个从保存到加载的过程称之为上下文切换。

什么是线程死锁,如何避免

假如有两个线程A和B,他们执行时都需要资源1和资源2。线程A先执行,获取了资源1,然后暂停,调用线程B,B获取了资源2,然后要等待A线程释放资源1,而A线程要等待线程B释放资源2,在等待的过程中,A和B都处于阻塞状态没有执行。

想要避免死锁,可以使用无锁函数(cas)或者使用重入锁(ReentrantLock),通过重入锁使线程中断或限时等待可以有效的规避死锁问题。

sleep()方法和wait()方法的区别和共同点

共同点:

都可以暂停线程的执行

区别:

sleep没有释放锁,而wait释放了锁
wait常被用于线程之间的交互和通讯,而sleep常被用于执行暂停
wait被调用后,线程不会自动苏醒,需要其他线程调用同一对象上的notify()或者notifyAll()来唤醒,或者给wait添加事件参数,超时后自动苏醒。
sleep在执行完毕后会自动苏醒。
sleep是Thread的方法,wait是Object的synchronized方法块中的方法
调用sleep线程进入runnable状态,而调用wait线程进入阻塞状态

为什么调用start方法会执行run方法,为什么不直接调用run方法?

调用start方法,会调用该线程来跑run方法,但是如果直接调用run方法,就等于是主线程调用了run方法。并没有启动一个新的线程。

volatile关键字

TIP:由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。

volatile内存语义

volatile在并发编程中很常见,但也很容易被滥用,现在我们就进一步分析volatile关键字的语义。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用:

  • 保证被Volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰的共享变量的值,新值总是可以被其他线程得知。
  • 禁止指令重新排序优化
volatile的特性
  • 可见性: 对一个volatile的变量的读,总是能看到任意线程对这个变量最后的写入。
  • 单个读或者写具有原子性: 对于单个volaile变量的读或者写具有原子性,复合操作不具有。如(i++);
  • 互斥性: 同一时刻只允许一个线程对变量进行操作。(互斥锁的特点)
volatile重排序

image

关于jvm和编译器的重排序

比如有以下一段代码:
a=1; //1
b=a+1; //2
那么操作2是依赖于操作1的,在有依赖情况下,编译器是不会对指令的顺序重新编排的。as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

a=1; //1
b=1; //2
c=a+b; //3
操作3是依赖操作1和2的,但是1和2之间没有依赖的关系,也就是说编译器和处理器可以对操作1和操作2进行重排序,有可能执行顺序变成 2 -> 1 -> 3

总结

  • 当第一个操作是 volatile读时,不管第二个操作是什么,都不能重排序.确保volatile读之后的操作不会被重排序到 volatile读之前.

  • 当第二个操作是 volatile写时,不管第一个操作是什么,都不能重排序.确保volatile写之前的操作不会被重排序到volatile写之后.

  • 当第一个操作是 volatile写,第二个操作是 volatile读时,不能重排序.

为了实现volatile的内存语义,在编译器生成字节码时,会在指令序列之中插入内存屏障来进制特定类型的处理器重排序。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

image

image

StoreStore屏障 可以保证在volatile写之前,其前面的所有普通写操作已经对任
意处理器可见了。

StoreLoad屏障 将 volatile写操作刷新到内存.

由此达到, volatile写 立马刷新到主内存的效果.

LoadLoad屏障 保障后续是读操作时, volatile读装载到内存数据.
LoadStore屏障 保障后续是写操作时, volatile读装载到内存数据.

由此达到, volatile读 从主内存中读取共享变量,并更新本地内存的值.

volatile的使用条件
  • 对变量的写操作不依赖于当前值(i++就不符合条件)
  • 该变量没有包含在其他变量的不变式中
正确使用volatile的场景

状态标志
作为一个布尔状态标志,标志了一个重要事件的发生,例如初始化完成或者任务结束。状态不依赖其他变量,只是false/ture的一个转换

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // todo...
    }
}

一次性安全发布

//基于volatile的解决方案
public class SafeDoubleCheckSingleton {
    //通过volatile声明,实现线程安全的延迟初始化
    private volatile static SafeDoubleCheckSingleton singleton;
    private SafeDoubleCheckSingleton(){
    }
    public static SafeDoubleCheckSingleton getInstance(){
        if (singleton == null){
            synchronized (SafeDoubleCheckSingleton.class){
                if (singleton == null){
                    //原理利用volatile在于 禁止 "初始化对象"(2) 和 "设置singleton指向内存空间"(3) 的重排序
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }
        return singleton;
    }
}

独立观察

class CustomLinkedList{
    public volatile Node lastNode;
    .....
    public void add() {
        Node node = new Node();
        .....
        lastNode = node;//将新节点作为最后一个节点
    }
}

开销较低的读写锁策略

public class Counter {
    private volatile int value;
    //利用volatile保证读取操作的可见性, 读取时无需加锁
    public int getValue() { return value; }
    // 使用 synchronized 加锁
    public synchronized int increment() { 
        return value++;
    }
}

ThreadLocal

ThreadLocal是什么

ThreadLocal并不是一个Thread,而是Thread的局部变量,也许把它命名为ThreadLocalVariable更容易让人理解一些。
在JDK5.0中,ThreadLocal已经支持泛型,该类的类名已经变为ThreadLocal。API方法也相应进行了调整,新版本的API方法分别是void set(T value)、T get()以及T initialValue()。
ThreadLocal是解决线程安全问题一个很好的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。

ThreadLocal怎么用

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

  1. 在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
  2. 线程间数据隔离
  3. 进行事务操作,用于存储线程事务信息。
  4. 数据库连接,Session会话管理。
ThreadLocal原理

调用ThreadLocal的set方法时会获取当前线程,再获取当前线程下ThreadLocalMap,再从map获取数据,key值为ThreadLocal,不同的范型为不同的key,相同范型只能存储一个数据

会造成内存泄漏问题

ThreadLocal是弱引用,有时候ThreadLocal被垃圾系统回收了,但是value还存在。
所以要再get完之后使用remove。

线程池

为什么用线程池

如果再java中每收到一个请求就要创建一个线程,那么线程的创建和销毁的开销可能会大于实际执行所用的开销,影响系统的性能。而且线程也不是越多越好,如果线程超过CPU核心数量,就需要并发执行,线程之间的切换也是要消耗性能。所以最好是把线程的数量控制再一个范围之内,所以就有了线程池。
而线程池里的每一个线程任务结束后,并不会死亡,而是再次回到线程池中成为空闲状态,等待下一个对象来使用,因而借助线程池可以提高程序的执行效率。

如何使用线程池

JDK1.5之后提供了Executors工具类来创建线程池

Executors.newCachedThreadPool 创建可缓存无限数量的线程池,超过60秒没有使用就会回收。也就是没多一个任务就会多建一个线程来处理,处理完成后60秒内没有其他任务就会销毁。

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

Executors.newFixedThreadPool 创建固定大小的线程池,超过线程池数量的任务会放入等待队列

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

Executors.newSingleThreadExecutor 创建线程数量为1的线程池,可以理解为使用 Executors.newCachedThreadPool(1)创建的线程池,目的是为了保证任务按照顺序执行

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

Executors.newScheduledThreadPool 创建固定大小的线程池,支持定时以及周期性的任务执行

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}
Executors创建线程池的原理

上面四种线程池的创建,底层都是用new ThreadPoolExecutor()来实现的,让我们看下ThreadPoolExecutor需要传入的参数

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

corePoolSize 线程池中线程的数量。
maximumPoolSize 线程池中最大线程的数量。
keepAliveTime 当存在的线程数大于corePoolSize,那么会找到空闲线程去销毁,此参数是设置空闲多久的线程才被销毁。
unit 等待的时间的单位 使用TimeUnit的静态成员变量。
workQueue 工作队列,线程池中的当前线程数大于核心线程的话,那么接下来的任务会放入到队列中。
threadFactory 在创建线程的时候,通过工厂模式来生产线程。这个参数就是设置我们自定义的线程创建工厂。
handler 如果超过了最大线程数,那么就会执行我们设置的拒绝策略。

为什么不推荐使用FixedThreadPool?

FixedThreadPool和SingleThreadExecutor:
这两个线程池的实现方式,我们可以看到它设置的工作队列都是LinkedBlockingQueue,我们知道此队列是一个链表形式的队列,此队列是没有长度限制的,是一个无界队列,那么此时如果有大量请求,就有可能造成OOM。
CachedThreadPool和ScheduledThreadPool:
这两个线程池的实现方式,我们可以看到它设置的最大线程数都是Integer.MAX_VALUE,那么就相当于允许创建的线程数量为Integer.MAX_VALUE。此时如果有大量请求来的时候也有可能造成OOM。

四种拒绝策略:

AbortPolicy 直接抛出异常

public static class AbortPolicy implements RejectedExecutionHandler {
    public AbortPolicy() { }
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
         throw new RejectedExecutionException("Task " + r.toString() +
        " rejected from " +
         e.toString());
 }
}

CallerRunsPolicy 会调用execute函数的上层线程来执行任务

public static class CallerRunsPolicy implements RejectedExecutionHandler {
    public CallerRunsPolicy() { }
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
            r.run();
        }
    }
}

DiscardPolicy 什么都不做,相当于直接抛弃仍任务

public static class DiscardPolicy implements RejectedExecutionHandler {
    public DiscardPolicy() { }
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    }
}

DiscardOldestPolicy 抛弃队列中最先加入的任务,然后把这个任务加入队列

public static class DiscardOldestPolicy implements RejectedExecutionHandler {
    public DiscardOldestPolicy() { }
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
            e.getQueue().poll();
            e.execute(r);
        }
    }
}

也可以通过实现来创建自定义的策略,比如新建一个线程来执行这个任务

static class MyRejectedExecutionHandler implements RejectedExecutionHandler {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        new Thread(r,"新线程"+new Random().nextInt(10)).start();
    }
}

AQS

AQS是什么

AQS 的全称为(AbstractQueuedSynchronizer),这个类在 java.util.concurrent.locks 包下面。AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask(jdk1.7) 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。

AQS的原理

原理概览

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS基本框架如图
image
AQS维护了一个volatile语义(支持多线程下的可见性)的共享资源变量state和一个FIFO线程等待队列(多线程竞争state被阻塞时会进入此队列)。

private volatile int state;

// 具有内存读可见性语义
protected final int getState() {
    return state;
}

// 具有内存写可见性语义
protected final void setState(int newState) {
    state = newState;
}

// 具有内存读/写可见性语义
protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

以上三个方法都是原子性的,compareAndSetState是依赖unsafe.compareAndSwapInt来实现原子性的。

资源的共享方式分为两种:

  • 独占式(Exclusive)
    只有单个线程能够成功获取资源并执行,如ReentrantLock。
  • 共享式(Shared)
    多个线程可成功获取资源并执行,如Semaphore/CountDownLatch等。

AQS将大部分的同步逻辑均已经实现好,继承的自定义同步器只需要实现state的获取(acquire)和释放(release)的逻辑代码就可以,主要包括下面方法:

  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

AQS需要子类复写的方法均没有声明为abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。

当然,AQS也支持子类同时实现独占和共享两种模式,如ReentrantReadWriteLock。

锁的常见分类
  1. 可重入锁和非可重入锁
  2. 公平锁和非公平锁
  3. 读写锁和排他锁
Synchronized关键字
对synchronized的了解

synchronized是一个修饰关键字,可以用来修饰方法和代码块

怎么使用synchrinized关键字

synchronized可以用在方法上也可以用在代码块中,其中方法是实例方法和静态方法,分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,实例对象,和类对象,和任意实例对象Object,锁住的分别是实例对象,类对象,实例对象。

synchronized的原理

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

修饰代码块时

首先要执行monitorenter指令,退出的时候执行monitorexit指令。通过分析之后可以看出,使用synchronized进行同步,其关键就是必需要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。synchronized先天具有重入性,即在同一个线程中,已经获得某个对象锁的情况下,在后面的执行中又要获得对象的资源时不会在重新获取一次锁。每个对象拥有一个计数器,当线程获取该对象后,计数器就会加一,释放锁后就会将计数器减一。

修饰方法名时

使用synchroonized修饰方法名时并没有monitorenter和monitorexit指令。而是用ACC_SYNCHRONIZED的flag标记该方法是否同步方法,从而执行相应的同步调用。

每一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必需先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处。

JDK1.6以后对synchronized做的优化

JDK1.6之后对锁的实现引入了大量的优化,如自旋锁,适应性自旋锁,锁消除,锁粗化,偏向锁,轻量级锁等技术来减少锁操作的开销。

锁主要存在四种状态:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。他们会随着竞争激烈而不断升级。锁可以升级,但是不能降级,这是为了提高获得锁和释放锁的效率。

自旋锁

线程的阻塞和唤醒需要CPU从用户态转换为核心态,频繁的阻塞和唤醒对cpu来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在很多应用上面,对象锁的状态指挥持续一段很短的时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。自旋锁的作用就是让线程等待一段时间,不会被立刻挂起,看持有锁的线程会不会很快释放线程。实现原理,执行一段无意义的循环,默认为十次。

自适应自旋锁

JDK1.6之后引入了更聪明的自旋锁,自适应自旋锁。所谓自适应就意味着自旋的次数不是固定的而是由前一次在同一个锁的自旋时间及锁的拥有者的状态决定的。实现原理,线程如果自旋成功,那么下次自旋的次数会更多;反正,如果对于某个锁,很少有自旋能够成功的,那么以后这个锁的次数会减少甚至省略掉自旋的过程,以免浪费处理器资源。

锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是有些情况下,JVM检测到不可能存在的共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。有时候我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。JVM检测到变量没有逃逸方法之外的,就可以大胆的将内部加锁操作消除。

轻量级锁

引入轻量级锁的作用主要是在没有多线程竞争的前提下,减少传统的重量级锁使用在操作系统中互斥产生的性能消耗。关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

偏向锁

引入偏向锁的主要目的是为了在无多线程竞争的情况下,尽量减少不要的轻量级锁执行路径。偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动释放偏向锁,需要等待其他线程来竞争。(获得偏向锁的线程,即是不使用这个资源了也还是拥有锁(为了防止后面这个线程还是要用这个资源,减低无意义的CAS操作来加锁和解锁),直到其他线程来竞争这个锁)

重量级锁

当竞争锁的线程超过两个就从轻量级锁变成重量级锁,重量级锁是通过对象内部的监视器monitor实现的,操作系统线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

锁粗化

锁粗化就是将多个连续的加锁、解锁操作连到一起,扩展成更多范围的锁。
JVM检测到用一个对象有连续的加锁、解锁操作,会合并称为一个更大范围的加锁、解锁操作,例如将加锁操作移动到for循环外面。

谈谈 synchronized 和 ReentrantLock 的区别

相同:

synchronized和ReentrantLock都具有可重入性,两者关于重入性的区别不大,都是同一个线程每进入一次,锁的计数就自增1,要等到锁的技术为0时,才释放锁。

不同:

synchronized是依赖JVM实现的,而ReetrantLock是JDK实现的。
在优化之前,synchronized的性能和reentrantLock差很多,但是自从synchronized引入了偏向锁,轻量级锁后,两者的性能就差不多了。除此之外,ReentrantLock还具有一些高级功能,1.等待中断,中断等待获取锁的线程,让线程放弃等待而去做其他事情。2.可实现公平锁,即是谁先来,谁就先获得资源,而不是synchronized的随机分配。3.可实现选择性通知,可是condition条件唤醒某些线程,而不是synchronized唤醒一个线程或这唤醒所有线程。

使用例子

        Lock lock = new ReentrantLock(false);    
        Condition write = lock.newCondition();
        Condition read = lock.newCondition();
        Queue<Integer> queue = new ArrayBlockingQueue<>(10);
        Runnable task1 = () -> {
            lock.lock();
            try {
                for (int i = 0; i < 100; ++i) {
                    while(queue.size()==10) {
                        write.await();
                    }
                    System.out.println("add "+(i+1));
                    queue.add(i+1);
                    read.signal();
                }  
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }

        };

        Runnable task2 = () -> {
            lock.lock();
            try {
                for (int i = 0; i < 100; ++i) {
                    while(queue.size()==0) {
                        read.await();
                    }
                    System.out.println(queue.poll());
                    write.signal();
                }  
            } catch (Exception e) {
                e.printStackTrace();
            } finally { 
                lock.unlock();
            }
        };

condition的使用
condition是通过reentrantLock的实例对象的newCondition方法生成的对象,可以生成多个condition,可以理解为信号量。condition.singal()等于释放信号量,condition.await()表示消耗信号量。

ReadWriteLock

ReadWriteLock管理一组锁,一个是只读的锁,一个是写锁,读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。

特性:

可重入,写锁可以获得读锁,读锁不能获得写锁

降级锁,允许写锁降低为读锁

中断锁的获取,在读锁和写锁的获取过程中可支持中断

支持condition,只有写锁支持condition

监控,提供确定锁是否被持有等辅助方法

总结:

  • 读锁和读锁之间不互斥,不会阻塞
  • 读锁和写锁互斥,读阻塞写,写阻塞读
  • 写锁和写锁互斥,互相阻塞

当分析ReentranctReadWriteLock时,或者说分析内部使用AQS实现的工具类时,需要明白的就是AQS的state代表的是什么。ReentrantLockReadWriteLock中的state同时表示写锁和读锁的个数。为了实现这种功能,state的高16位表示读锁的个数,低16位表示写锁的个数。AQS有两种模式:共享模式和独占模式,读写锁的实现中,读锁使用共享模式;写锁使用独占模式;另外一点需要记住的即使,当有读锁时,写锁就不能获得;而当有写锁时,除了获得写锁的这个线程可以获得读锁外,其他线程不能获得读锁。

使用例子

    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock rLock = rwLock.readLock();
    private final Lock wLock = rwLock.writeLock();
    private int[] counts = new int[10];
    
    public void inc(int index) {
        wLock.lock();
        try {
            counts[index] += 1;
        }finally {
            wLock.unlock();
        }
    }
    
    public int[] get() {
        rLock.lock();
        try {
            return Arrays.copyOf(counts, counts.length);
        }finally {
            rLock.unlock();
        }
    }
StampedLock(JDK8)
stampedLock是什么

StampedLock是对ReentranReadWriteLock的优化,加入了三种模式

  • 读模式
  • 写模式
  • 乐观读模式
为什么有了ReentrantReadWriteLock还要StampedLock

因为读锁和写锁是互斥的,如果现在有两个线程分别执行读和写任务。如果执行读的线程一直占据读锁,那么就会造成写的饥饿现象,直到读都快完成了,写才开始。虽然reentrantReadWriteLock有公平锁,但是公平策略是以牺牲吞吐量为代价的。而stampedLock有个乐观读模式,读的时候没有上锁,而是通过对比stamp来判断是否被更改,如果数据被更改了再上悲观读锁,所以当线程执行乐观读模式时,写线程可以获得写锁,不会导致饥饿现象发生。

使用实例

public class TestStampedLock {

    public static void main(String[] args) {
        Point point = new Point();
        ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 100, 0, TimeUnit.SECONDS,
                new ArrayBlockingQueue<Runnable>(4));
        CountDownLatch countDownLatch = new CountDownLatch(2);
        Runnable task1 = () -> {
            for (int i = 0; i < 5; ++i) {
                point.move(1, 1);
            }
            countDownLatch.countDown();
        };

        Runnable task2 = () -> {
            for (int i = 0; i < 50; ++i) {
                System.out.println("---- " + point.distance());
            }
            countDownLatch.countDown();
        };
        executor.submit(task2);
        executor.submit(task1);
        executor.shutdown();
    }

}

class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    void move(double deltaX, double deltaY) {
        // 获取写锁
        long stamp = lock.writeLock();
        try {
            // 写入数据
            x += deltaX;
            y += deltaY;
            System.out.println("写入数据  x=" + x + " y=" + y);
        } finally {
            // 释放写锁
            lock.unlockWrite(stamp);
        }
    }
    //使用了乐观读锁
    double distanceFromOrigin() {
        // 获取乐观读锁
        long stamp = lock.tryOptimisticRead();
        // 读取数据
        double currentX = x, currentY = y;
        // 通过判断stamp和原来的是否想等来判断数据是否被写入过
        if (!lock.validate(stamp)) {
            // 如果被写入过,则需要获取悲观读锁
            stamp = lock.readLock();
            try {
                // 再次获取数据
                currentX = x;
                currentY = y;
            } finally {
                // 释放读锁
                lock.unlockRead(stamp);
            }
        }
        // 返回运算结结果
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
    //没有使用乐观读锁
    double distance() {
        long stamp = lock.readLock();
        double currentX, currentY;
        try {
            currentX = x;
            currentY = y;
        } finally {
            lock.unlockRead(stamp);
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    void moveIfOrigin(double deltaX, double deltaY) {
        long stamp = lock.readLock();
        try {
            while (x == 0 && y == 0) {
                long ws = lock.tryConvertToWriteLock(stamp);
                if (ws != 0) {
                    stamp = ws;
                    x += deltaX;
                    y += deltaY;
                } else {
                    lock.unlockRead(stamp);
                    stamp = lock.writeLock();
                }
            }
        } finally {
            lock.unlock(stamp);
        }
    }
}

上面代码如果任务2不使用乐观读的话,有可能会造成饥饿现象。

Atomic与CAS

Cas
cas是什么

CAS操作是乐观锁,每次不加锁而是去假设没有冲突去完成某项操作,如果因为冲突失败就重试,直到成功为止。假设现有线程1 2,共享值v=A,线程1获得v的值为A并修改为B,但是获得值后还没将修改的值写入主存就切换到线程2,线程获得v的值A,并修改为了C写回了主存。再切换回线程1,线程1将B写入主存是发现,原值已经从A变成了C,修改失败并重试直到成功为止。

cas的原理

java 的 cas 利用的的是 unsafe 这个类提供的 cas 操作。
unsafe 的cas 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg
Atomic::cmpxchg 的实现使用了汇编的 cas 操作,并使用 cpu 硬件提供的 lock信号保证其原子性

ABA问题

时刻1:线程1执行读取操作,获取原值 A,然后线程被切换走
时刻2:线程2执行完成 CAS 操作将原值由 A 修改为 B
时刻3:线程2再次执行 CAS 操作,并将原值由 B 修改为 A
时刻4:线程1恢复运行,将比较值(compareValue)与原值(oldValue)进行比较,发现两个值相等。
然后用新值(newValue)写入内存中,完成 CAS 操作

如上流程,线程1并不知道原值已经是被修改过了,在它看来并没有什么变化,所以它回继续往下执行流程。

解决方法:
对于ABA问题,通常解决方法就是每一次CAS操作修改一次版本号,版本号是 递增的,保证每次修改版本号都不一样。
java.util.concurrent.atomic 包下提供了一个可处理 ABA 问题的原子类 AtomicStampedReference

Atomic原子类
Atmoic原子类是什么

原子是构成一般物质的最小单位,是不可分割的。在这里Atomic表示当前操作是不可中断的,即使是在多线程环境下执行,Atomic类是具有原子操作的类。
java的原子类都放在了java.util.concurrent.atomic包下

JUC包中原子类是哪四类
  • 基本类型:AtomicInteger, AtomicLong, AtomicBoolean ;
  • 数组类型:AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray ;
  • 引用类型: AtomicReference, AtomicStampedRerence, AtomicMarkableReference ;
  • 对象的属性修改类型:AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater ;
AtomicInteger的使用
AtomicInteger(int initalValue)//创建值为initalValue的AtomicInteger对象

set(int newValue)//以原子的方式设置当前值为newValue

get() //获取当前值

getAndSet(int newValue)//以原子方式返回旧值,并设置为新值

compareAndSet(int expect, int update)//如果当前值为expect,则以原子方式将值设置为update,成功返回true,失败返回false且不修改原值

decrementAndGet()//等于原子方式的--num

getAndDecrement()//等于原子方式的num--

getAmdIncrement()//等于原子方式的num++

incrementAndGet()//等于原子方式的++num

addAndGet(int delta)//以原子方式将值和delta相加并返回

getAndAdd(int delta)//返回原值,再以原子方式将原值和delta相加
AtmoinInteger的原理

Atomic基础类的方法用了很多的CAS函数来是实现。

比如incrementAndGet()方法

public final long incrementAndGet() {
    for (;;) {
        // 获取AtomicLong当前对应的long值
        long current = get();   //得到的是返回的旧值
        // 将current加1
        long next = current + 1;
        // 通过CAS函数,更新current的值
        if (compareAndSet(current, next))  //如果旧值和内存中的相等,则更新为新值
            return next;
    }
}

并发容器

  • ConcurrentHashMap: 线程安全的 HashMap
  • CopyOnWriteArrayList: 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector.
  • ConcurrentLinkedQueue: 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
  • BlockingQueue: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。

学习过程中,参考了一些大佬的文章和博客,算是作为一次学习的总结和笔记。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值