Java专题学习——2.并发与多线程

一 并发与并行

1.并发(Concurrency)

是指同一个时间段内多个任务同时都在执行,并且都没有执行结束。并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行。

2.并行(Parallelism)

是说在单位时间内多个任务同时在执行。在多线程编程实践中,线程的个数往往多于CPU的个数,所以一般都称多线程并发编程而不是多线程并行编程。

3.理解

并发是指在某个时间段内,多任务交替处理的能力。CPU会把可执行的时间均分成若干份,每个进程执行一段时间后,记录当前的工作状态,释放相关的执行资源,进入等待状态,让其他线程来抢占CPU资源。

并行是指同时处理多任务的能力。目前,CPU早已发展到多核,可以同时执行多个互不依赖的指令及执行块。

并发与并行的核心区别在于进程是否同时进行(并发不是同时进行,并行是同时进行)。

并发与并行的目标都是尽可能快的执行完所有任务。例如超市收银,一个超市有两位收银员,他们用两台电脑同时可以收银、互不干扰,这就是两个并行任务;而其中一个收银员有时在收银,有时去货柜为顾客更换商品,然后继续收银,有时又会被领导叫去交代注意事项,收银暂时中断,这就是并发。

在并发的环境下,程序的封闭性被打破,出现了这样的特点:

(1)并发程序之间有相互制约的关系。直接制约体现在一个程序需要另一个程序的计算结果;间接体现为多个程序竞争共享资源,如处理器、缓冲区等。

(2)并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点。

(3)当并发数设置合理并且CPU拥有足够的处理能力时,并发会提高程序的运行效率

4.并发中常见问题

(1)线程安全问题

当多个线程同时操作共享变量1时,会出现线程1更新共享变量1的值,但其他线程获取到的是共享变量1没有被更新前的值,所以就会导致数据不准确问题

(2)共享内存不可见性问题

Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间(工作内存),线程读写变量时操作的是自己工作内存中的变量。(如下图左)

下图右所示的是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。CPU的每个核心都有自己的一级缓存,在有些架构里面还有一个所有CPU都共享的二级缓存。 这样,Java内存模型里面的工作内存,就对应这里的 Ll或者 L2 缓存或者 CPU 的寄存器。现在设想如下流程:

1、线程1首先想要获取共享变量1(下称X)的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程1修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程1操作完毕后,线程1所在的CPU的两级Cache内和主内存里面的X的值都是l。

2、线程2也尝试获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里一切正常。然后,线程2修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中,最后更新主内存中X的值为2。

3、线程1这时又需要修改X的值,获取时一级缓存命中,并且得到X=1。这时问题就出现了,线程2已经把X的值修改为2,可是线程1获取的还是值为1。

这就是共享变量的内存不可见问题,也就是线程2写入的值对线程1不可见

                                   

二 线程与进程

1.进程

代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位

多进程:在操作系统能同时运行多个任务。

2.线程

是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。虽然系统是把资源分给进程,但是CPU很特殊,是被分配到线程的,所以线程是CPU分配的基本单位。(CPU是被分配到线程的,系统是把资源分配到进程)

多线程:在同一个应用程序中有多个顺序流同时执行。

3.关系

一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。

1) 一个程序至少有一个进程,一个进程至少有一个线程.

2) 线程的划分尺度小于进程,使得多线程程序的并发性高。

3)进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。

4) 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

5) 从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。

线程和进程关系图如下,相关概念:

1.程序计数器是一块内存区域,用来记录线程当前要执行的指令地址 。

2.栈用于存储该线程的局部变量,这些局部变量是该线程私有的,除此之外还用来存放线程的调用栈祯。

3.堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的。

4.方法区则用来存放 NM 加载的类、常量及静态变量等信息,也是线程共享的 。

三 Java线程

1.创建、启动、中断线程方法

每个线程都通过执行Runnable对象中的run()方法来开始他的生命周期。run方法可以包含任何代码,但是它必须是共有的,不带参数,没有返回值,不抛异常。

具有适合自己的run()方法的每一个类都能声明实现类Runnable接口。之后这个类的实例就是一个作为新线程目标的可运行的对象。

(1)继承Thread类

创建了java.lang.Thread类的实例时,也就创建了一个新的线程。Thread对象表示Java解释器中一个真实的线程,并为控制和同步线程提供handle句柄。(Thread在内部也是实现了Runnable接口)

需要重写run方法,使用Thread对象可以启动、中止或临时暂停一个线程。

public class ThreadTest {
    public static void main(String[] args) {
        A a1=new A();
        A a2=new A();
        a1.setName("线程A");//设置线程名。
        a2.setName("线程B");
        a1.start();//启动两个线程a1、a2,出现交叉现象。
        a2.start();
        //a1.run();//直接调用run方法不能启动多线程!
    }
}
class A extends Thread{
    @Override
    public void run() {
        for (int i=0;i<50;i++){
            System.out.println(Thread.currentThread().getName()+"->"+i);//currentThread 当前线程
        }
    }
}

(2)使用Runnable接口

B类实现Runnable接口,实现了Runnable接口的run方法。将B类的实例b1作为参数传给Thread线程的构造方法。这个方法创造的线程并不完全是面向对象的。

public class ThreadTest {
    public static void main(String[] args) {
        B b1=new B();
        B b2=new B();
        Thread t1=new Thread(b1);
        Thread t2=new Thread(b2);
        t1.setName("线程A");t2.setName("线程B");
        t1.start();t2.start();
    }
}
class B implements Runnable{
    @Override
    public void run() {
        for (int i=0;i<50;i++){
            System.out.println(Thread.currentThread().getName()+"->"+i);//currentThread 当前线程
        }
    }
}

(3)使用Callable接口

有时,我们需要在主线程中开启多个线程并发执行一个任务,然后收集各个线程执行返回的结果并将最终结果汇总起来,这时候就需要Callable接口。C类实现Callable接口,并通过FutureTask包装器来创建Thread线程。

public class CallableTest {

    public static void main(String[] args) {
        C c=new C();
        //实现Callable方式创建线程,需要用FutureTask实现类的支持,用来接收运算的结果。
        FutureTask<Integer> futureTask=new FutureTask<Integer>(c);// futureTask存结果
        Thread t=new Thread(futureTask);
        t.setName("A");
        t.start();//等价于 new Thread(futureTask).start();

        C c1=new C();
        //实现Callable方式创建线程,需要用FutureTask实现类的支持,用来接收运算的结果。
        FutureTask<Integer> futureTask1=new FutureTask<Integer>(c1);// futureTask存结果
        Thread t1=new Thread(futureTask1);
        t1.setName("B");
        t1.start();//等价于 new Thread(futureTask).start();

        try {
            System.out.println(futureTask.get());//回调、有返回值
            System.out.println(futureTask1.get());//回调、有返回值

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
class C implements Callable<Integer>{

    @Override
    public Integer call() throws Exception {

        for(int i=1;i<=100;i++){
            System.out.println(Thread.currentThread().getName()+i);
        }
        return 0;
    }
}

三种创建线程的区别:

1.使用Runnable接口:可以将线程代码和县城数据分开,形成清晰的模型;可以继承其他类。

2.直接继承Thread类编写简单,可以直接操纵线程。不能再从其他类继承(Java单继承机制)。

3.使用Callable回调式接口:

(1)使用Callable接口规定的方法是call(),而Ruunnable规定的方法是run()。

(2)Callable的任务执行后可返回值,而Runnable任务不能返回值。

(3)call()方法可以抛出异常,run()方法不能抛出异常。

(4)运行Callable任务可以拿到一个Fulture对象,Future表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可以取消任务进行,还可以或许任务执行的结果。
(5)Callable是类似于Runnable的接口,实现Callable接口的类和实现Runnable接口的类都是可以被其他线程执行的任务。

(4)启动线程——start

新的线程都将保持在空闲状态,直到调用它的start()方法来唤醒并开始执行目标对象的run方法在一个线程的生命周期中, start()方法只能调用一次。一旦线程启动之后,将一直保持运行状态,直到目标对象的run()方法返回才结束。
start()方法有一组可永久结束一个线程的stop()方法,在通常情况下不需要使用stop()方法结束线程,有更好的方法来终止线程。
(5)中断线程——interrupt

只有当线程处于sleep、wait、join状态时,interrupt才起作用。

interrupt方法用于向线程发出一个终止通知信号,会影响该线程内部的一个中断标识位,这个线程本身并不会因为调用了interrupt方法而改变状态(阻塞、终止等)。状态的具体变化需要等待收到中断标识的程序的最终处理结果来判定。
调用interrupt方法并不会中断一个正在运行的线程,也就是说处于Running状态的线程并不会因为被中断而终止,仅仅改变了内部维护的中断标识位而已。

2.线程的生命周期

线程在生命周期内存在多种状态,有NEW(新建状态)、RUNNABLE(就绪状态)、RUNNING(运行状态)、BLOCKED(阻塞状态)、DEAD(终止状态)五种状态。

(1)NEW,新建状态

线程被创建且未启动的状态。创建线程的方式有三种(继承自Thread类、实现Runnable接口、实现Callable接口)。

(2)RUNNABLE,就绪状态

调用start()之后运行之前的状态。线程的start()不能被多次调用(会抛出IllegalStateException异常)。

(3)RUNNING,运行状态

run()正在执行时线程的状态。线程可能会由于某些因素退出RUNNING,如时间、异常、锁、调度等。

(4)BLOCKED,阻塞状态

进入此状态,有以下三种情况。

同步阻塞:锁被其他线程占用。

主动阻塞:调用Thread的某些方法,主动让出CPU执行权,比如sleep()、join()等。

等待阻塞:执行了wait()。

(5)DEAD,中止状态

run()执行结束,或因异常退出后的状态。此状态不可逆转

3.线程池

(1)线程池的工作原理

为什么要使用线程池?

线程是处理器调度的基本单位,使应用能够更加充分合理地协调利用CPU、内存、网络、I/O等系统资源。我们会为每一个请求都独立创建一个线程,而操作系统创建线程、切换线程状态、结束线程都要使用CPU进行调度。频繁的创建和销毁线程会浪费大量的系统资源,增加并发编程风险。另外,如果服务器负载过大,如何让新的线程等待或者友好地拒绝服务?这些都是线程自身无法解决的。所以需要通过线程池来协调多个线程,并实现类似主次线程隔离、定时执行、周期执行等任务。使用线程池能够更好对线程进行管理、复用等

什么时候使用线程池?

1.单个任务处理时间比较短。   2.需要处理的任务数量很大。

Java中的线程池主要用于管理线程组及其运行状态,以便于Java虚拟机更好的利用CPU资源。Java线程池的工作原理是:

JVM先根据用户参数创建定数量的可运行的线程任务,并将其放入队列中,在线程创建后启动这些任务,如果线程数量超过了最大线程数量(用户设置的线程池大小),则超出数量的线程排队等候,在有任务执行完毕后,线程池调度器会发现有可用的线程,进而再次从队列中取出任务并执行。

线程池的主要作用是:

1.利用线程池管理并复用线程、控制最大并发数。

2.实现任务线程队列缓存策略和拒绝机制。

3.实现某些与时间相关的功能,如定时执行、周期执行等。

4.隔离线程环境。如交易和搜素两个服务在同一台服务器上,要分别开两个池。交易线程的资源消耗明显更大,因此通过配置独立的线程池,将较慢的交易服务与搜索服务隔离开,避免各服务线程相互影响。

总结:实现线程复用、线程资源管理,控制操作系统的最大并发数,以保证高效(通过线程资源复用实现) 安全(通过控制最大线程并发数实现)地运行。

线程复用:

在start方法中不断循环调用传递进来的Runnable对象,程序就会不断执行run方法中的代码。可以将在循环方法中不断获取的Runnable对象存放在Queue中,当前线程在获取下一个Runnable对象之前可以是阻塞的,这样既能有效控制正则执行的线程个数,也能保证系统中正在等待执行的其他线程有序执行。

线程池的优点:

1.降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

2.提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

3.提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

(2)线程池的构成

Java线程池主要由以下4个核心组件组成:
1.线程池管理器:用于创建并管理线程池。
2.工作线程:线程池中执行具体任务的线程。
3.任务接口:用于定义工作线程的调用和执行策略,只有线程实现了该接口,线程中的任务才能被线程池调度。
4.任务队列:存储待处理的任务,新的任务将会不断被加入队列中,执行完成的任务将被从队列中移除。

(3)线程池的原理

使用JDK1.8中ThreadPoolExecutor来分析线程池的原理(java.uitl.concurrent.ThreadPoolExecutor类是线程池中最核心的一个类)。下图是ThreadPoolExecutor类的实现/继承关系。

1.Executor(执行者)

执行提交的线程任务的对象。这个接口提供了一种将任务提交每个任务将如何运行实现分离的方法,包括线程使用、调度等细节。该接口只定义了一个execute()方法。

execute():将任务提交给线程池,由线程池为该任务创建线程并启动。该方法没有返回值,无法得到线程的执行结果。

2.ExecutorService(执行服务)

提供用于管理终止的方法(如shutDown()和shutDownNow()用于关闭线程池的方法),以及判断线程池是否关闭的方法(如isShutdown(),isTerminated()的方法)。提供了可以生成用于跟踪一个或多个异步任务进度的方法(如,invokeAll(),submit())。

这些方法的返回值都是Future类型,可以获取线程的执行结果

3.AbstartExecutorService(抽象的执行者服务)

基本上实现了ExecutorService声明的方法。

4.ThreadPoolExecutor(线程池执行者)

成员变量:

ctl是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段,ctl是一个Integer型, 它包含两部分的信息: 高3位表示线程池的运行状态 (runState) ,低29位表示线程池内有效线程的数量 (workerCount)。

构造函数:

corePoolSize:核心线程数量,当有新任务在execute()方法提交时,会执行以下判断:

a)如果运行的线程少于 corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的;

b)如果线程池中的线程数量大于等于 corePoolSize 且小于 maximumPoolSize,当workQueue未满的时候任务添加到workQueue中,当workQueue满时才创建新的线程去处理任务;

c)如果设置的corePoolSize 和 maximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理;

d)如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务;

所以,任务提交时,判断的顺序为 corePoolSize –> workQueue –> maximumPoolSize。

maximumPoolSize:最大线程数量;

workQueue:保存等待执行的任务的阻塞队列,当任务提交时,如果线程池中的线程数量大于等于corePoolSize的时候,把该任务封装成一个Worker对象放入等待队列。当提交一个新的任务到线程池以后, 线程池会根据当前线程池中正在运行着的线程的数量来决定对该任务的处理方式,主要有以下几种处理方式:

(a)直接切换:这种方式常用的队列是SynchronousQueue。

(b)使用无界队列:一般使用基于链表的阻塞队列LinkedBlockingQueue。如果使用这种方式,那么线程池中能够创建的最大线程数就是corePoolSize,而maximumPoolSize就不会起作用了。当线程池中所有的核心线程都是RUNNING状态时,这时一个新的任务提交就会放入等待队列中。

(c)使用有界队列:一般使用ArrayBlockingQueue。使用该方式可以将线程池的最大线程数量限制为maximumPoolSize,这样能够降低资源的消耗,但同时这种方式也使得线程池对线程的调度变得更困难,因为线程池和队列的容量都是有限的值,所以要想使线程池处理任务的吞吐率达到一个相对合理的范围,又想使线程调度相对简单,并且还要尽可能的降低线程池对资源的消耗,就需要合理的设置这两个数量。

如果要想降低系统资源的消耗(包括CPU的使用率,操作系统资源的消耗,上下文环境切换的开销等), 可以设置较大的队列容量和较小的线程池容量, 但这样也会降低线程处理任务的吞吐量。

如果提交的任务经常发生阻塞,那么可以考虑通过调用setMaximumPoolSize() 方法来重新设定线程池的容量。

如果队列的容量设置的较小,通常需要将线程池的容量设置大一点,这样CPU的使用率会相对的高一些。但如果线程池的容量设置的过大,则在提交的任务数量太多的情况下,并发量会增加,那么线程之间的调度就是一个要考虑的问题,因为这样反而有可能降低处理任务的吞吐量。

keepAliveTime:线程池维护线程所允许的空闲时间。当线程池中的线程数量大于corePoolSize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime;

threadFactory:它是ThreadFactory类型的变量,用来创建新线程。默认使用Executors.defaultThreadFactory() 来创建线程。使用默认的ThreadFactory来创建线程时,会使新创建的线程具有相同的NORM_PRIORITY优先级并且是非守护线程,同时也设置了线程的名称。

handler:它是RejectedExecutionHandler类型的变量,表示线程池的饱和策略。如果阻塞队列满了并且没有空闲的线程,这时如果继续提交任务,就需要采取一种策略处理该任务。线程池提供了4种策略:

AbortPolicy:直接抛出异常,这是默认策略;

CallerRunsPolicy:用调用者所在的线程来执行任务;

DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

DiscardPolicy:直接丢弃任务。

参考->线程池的拒绝策略:

若线程池中的核心线程数被用完且阻塞队列已排满,则此时线程池的线程资源已耗尽,线程池将没有足够的线程资源执行新的任务。
为了保证操作系统的安全,线程池将通过拒绝策略处理新添加的线程任务
JDK内置的拒绝策略有AbortPolicy、CallerRunsPolicy、Discard0ldestPolicy、DiscardPolicy这4种。默认的策略在
ThreadPoolExecutor中作为内部类提供。
1. AbortPolicy:直接抛出异常,阻止线程正常运行。
2. CallerRunsPolicy:如果被丢弃的线程任务未关闭,则执行该线程任务。
3. Discard0ldestPolicy:移除线程队列中最早的一个线程任务,并尝试提交当前任务。
4. DiscardPolicy:丢弃当前的线程任务而不做任何处理。

(4)线程池的创建

public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,
    long keepAliveTime,TimeUnit unit,BlockingQueue workQueue,RejectedExecutionHandler handler)

其中,

corePoolSize:线程池核心线程数量
maximumPoolSize:线程池最大线程数量
keepAliverTime:当活跃线程数大于核心线程数时,空闲的多余线程最大存活时间
unit:存活时间的单位
workQueue:存放任务的队列
handler:超出线程范围和队列容量的任务的处理程序

public class ThreadPoolTest2 {

    public static void main(String[] args) {
        //corePoolSize:线程池核心线程数量 maximumPoolSize:线程池最大线程数量
        // keepAliveTime:活跃线程数大于核心线程数时空闲多余线程最大存活时间 unit存活时间的单位 workQueue存放任务的队列
        // handler超出线程范围和队列容量的任务的处理程序
        ThreadPoolExecutor executor=new ThreadPoolExecutor(2,10, 200,
                TimeUnit.MICROSECONDS,new ArrayBlockingQueue<Runnable>(5));

        for (int i=0;i<15;i++){
            D d=new D(i);
            executor.execute(d);
            System.out.println("--------------------------");

            System.out.println("线程池种线程数目:"+executor.getPoolSize());

            System.out.println("队列中等待执行的任务数目:"+executor.getQueue().size());
            System.out.println("已经执行完毕任务数目:"+executor.getCompletedTaskCount());
            System.out.println("--------------------------");
        }
        executor.shutdown();//关闭线程池
    }
}
class D implements Runnable{
    private int num;

    public D(int num) {
        this.num = num;
    }

    @Override
    public void run() {
        //System.out.println("++++++++++++++++++++");

        System.out.println(Thread.currentThread().getName()+"正在执行任务"+num);
        try {
            Thread.currentThread().sleep(3000);//睡眠一会
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("任务"+num+"执行完成");
        System.out.println("++++++++++++++++++++");
    }
}

从执行结果可以看出,当线程池中线程的数目大于5时,便将任务放入任务缓存队列里面,当任务缓存队列满了之后,便创建新的线程。

不过在java doc中,并不提倡我们直接使用ThreadPoolExecutor,而是使用Executors类中提供的几个静态方法来创建线程池:

Executors.newCachedThreadPool();      //创建一个缓冲池,缓冲池容量大小为Integer.MAX_VALUE
Executors.newSingleThreadExecutor();  //创建容量为1的缓冲池
Executors.newFixedThreadPool(int);    //创建固定容量大小的缓冲池

从它们的具体实现来看,它们实际上也是调用了ThreadPoolExecutor,只不过参数都已配置好了

newFixedThreadPool创建的线程池corePoolSize和maximumPoolSize值是相等的,它使用的LinkedBlockingQueue;

newSingleThreadExecutor将corePoolSize和maximumPoolSize都设置为1,也使用的LinkedBlockingQueue;

newCachedThreadPool将corePoolSize设置为0,将maximumPoolSize设置为Integer.MAX_VALUE,使用的SynchronousQueue,也就是说来了任务就创建线程运行,当线程空闲超过60秒,就销毁线程。

实际中,如果Executors提供的三个静态方法能满足要求,就尽量使用它提供的三个方法。如果ThreadPoolExecutor达不到要求,可以自己继承ThreadPoolExecutor类进行重写。

(线程池的创建部分参考自:https://www.cnblogs.com/dolphin0520/p/3932921.html

(5)线程池的工作流程

任务提交给线程池之后的处理策略如下:

1.如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;

2.如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;

3.如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;

4.如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。

拓展->使用Executors来创建不同类型的线程池:https://www.jianshu.com/p/9beab78a3afe

4.sleep、join、yield、setDaemon方法

(1)线程睡眠

Thread.sleep(longmillis)和Thread.sleep(long millis, int nanos)静态方法强制当前正在执行的线程休眠(暂停执行),以“减慢线程”。当线程睡眠时,它入睡在某个地方,在苏醒之前不会返回到可运行状态。当睡眠时间到期,则返回到可运行状态。为了让其他线程有机会执行,可以将Thread.sleep()的调用放线程run()之内。这样才能保证该线程执行过程中会睡眠。

 注意:

1、线程睡眠是帮助所有线程获得运行机会的最好方法。

2、线程睡眠到期自动苏醒,并返回到可运行状态,不是运行状态。sleep()中指定的时间是线程不会运行的最短时间。因此,sleep()方法不能保证该线程睡眠到期后就开始执行。

3、sleep()是静态方法,只能控制当前正在运行的线程。

(参考自https://blog.csdn.net/kwame211/article/details/78963044

sleep和wait方法的区别:

1. sleep方法属于Thread类,wait方法属于0bject类。
2. sleep方法暂停执行指定的时间,让出CPU给其他线程,但其监控状态依然保持,在指定的时间过后有会自动恢复运行状态。
3.在调用sleep方法的过程中,线程不会释放对象锁。
4.在调用wait方法时,线程会放弃对象锁,进入等待此对象的等待锁池,只有针对此对象调用notify方法后,该线程才能进入对象锁池准备获取对象锁,并进入运行状态。

(2)join线程

Thread类提供了让一个线程等待另一个线程完成的方法—— join()方法,当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join方法加入的join线程完成为止。例如,让一个线程B“加入”到另外一个线程A的尾部。在A执行完毕之前,B不能工作。
join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题均分配一个线程,当所有的小问题都得到处理后,再调用主线程来进一步操作。

另外,join()方法还有带超时限制的重载版本。例如t.join(3000);则表示让线程等待3000毫秒,如果超过这个时间,则停止等待,变为可运行状态。

(3)线程让步(线程的优先级)

线程的让步是通过Thread.yield()来实现的。作用是暂停当前正在执行的线程对象,并执行其他线程。

要理解yield(),必须了解线程的优先级的概念。线程总是存在优先级,优先级范围在1~10之间。JVM线程调度程序是基于优先级的抢先调度机制。在大多数情况下,当前运行的线程优先级将大于或等于线程池中任何线程的优先级。但这仅仅是大多数情况。

当设计多线程应用程序的时候,一定不要依赖于线程的优先级。因为线程调度优先级操作是没有保障的,只能把线程优先级作用作为一种提高程序效率的方法,但是要保证程序不依赖这种操作。当线程池中线程都具有相同的优先级,调度程序的JVM实现自由选择它喜欢的线程。这时候调度程序的操作有两种可能:一是选择一个线程运行,直到它阻塞或者运行完成为止。二是时间分片,为池内的每个线程提供均等的运行机会。

设置线程的优先级:线程默认的优先级是创建它的执行线程的优先级。可以通过setPriority(int newPriority)更改线程的优先级。线程优先级为1~10之间的正整数,JVM从不会改变一个线程的优先级。然而,1~10之间的值是没有保证的。一些JVM可能不能识别10个不同的值,而将这些优先级进行每两个或多个合并,变成少于10个的优先级,则两个或多个优先级的线程可能被映射为一个优先级。        线程默认优先级是5,Thread类中有三个常量,定义线程优先级范围:

  1. static intMAX_PRIORITY:线程可以具有的最高优先级。  
  2. static intMIN_PRIORITY:线程可以具有的最低优先级。  
  3. static intNORM_PRIORITY:分配给线程的默认优先级。

yield()做的是让当前运行线程回到可运行状态以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。yield()从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。

(4)守护线程

setDaemon()方法标记一个线程是守护线程。守护线程优先级较低,用于为系统中的其他对象和线程提供服务。比如垃圾回收线程是一个经典的守护线程,如果在我们的程序中不再有任何线程运行时,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以在回收JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低状态下运行,用于实时监控和管理系统中的可回收资源。

用户线程和守护线程的区别:
用户线程: Java虚拟机在它所有用户线程都离开后则Java虛拟机才离开。
守护线程:它依赖于JVM,与JVM”共生死“, 在JVM中的所有线程都是守护线程了,JVM就可以退出了,如果还有一个或一个以上的非守护线程,JVM就不会退出。

5.线程之间的通信

实例:模拟银行取、存钱

假设现在系统中有两条线程,这两条线程分别代表存款者和取钱者,假设系统要求存款者和取钱者不断的重复存款和取款动作,而且要求每当存款者将钱存入指定账户后,取钱者就立即取出,不允许存款者连续两次存钱,也不允许取钱者连续两次取钱。
(1)Account.java

/*
模拟银行存钱取钱,一个人取钱,几个人存钱,规定存钱和取钱不能挨着,
 */
public class Account {
    private String accountId;
    private double balance;//余额
    private boolean flag=false;//标识存款 有存款为ture


    public Account(){}
    public Account(String accountId,double balance){
        this.accountId=accountId;
        this.balance=balance;
    }

    public void setAccountId(String accountId) {
        this.accountId = accountId;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    public synchronized void draw(double drawAmount) throws InterruptedException {
        if(!flag){//没人存钱,等一会
            wait();
        }
        else{
            balance-=drawAmount;
            System.out.println(Thread.currentThread().getName()+"取出"+drawAmount);
            System.out.println("现在余额为:"+balance);
            //Thread.currentThread().sleep(100);
            flag=false;
            notifyAll();//唤醒其他等待线程
        }
    }
    public synchronized void deposit(double depositAmount) throws InterruptedException {
        if(flag){//有人在存钱,等一会
            System.out.println("等待中:"+Thread.currentThread().getName());
            wait();
        }
        else{
            balance+=depositAmount;
            System.out.println(Thread.currentThread().getName()+"存入"+depositAmount);
            System.out.println("现在余额为:"+balance);
            //Thread.currentThread().sleep(100);
            flag=true;
            notifyAll();//唤醒其他等待线程
        }
    }
}

(2)DrawThread.java

public class DrawThread extends Thread{
    private Account account;
    private double drawAmount;

    public DrawThread(String name,Account account,double drawAmount){
        super(name);
        this.account=account;
        this.drawAmount=drawAmount;
    }

    public void run(){
        for(int i=0;i<5;i++){
            try {
                account.draw(drawAmount);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

(3)DepositThread.java

public class DepositThread extends Thread{
    private Account account;
    private double depositAmount;

    public DepositThread(String name,Account account,double depositAmount){
        super(name);
        this.account=account;
        this.depositAmount=depositAmount;
    }

    public void run(){
        for(int i=0;i<5;i++){
            try {
                account.deposit(depositAmount);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}

(4)main: Test.java

public class Test {
    public static void main(String[] args) {
        Account account=new Account("test",0);
        new DrawThread("取钱的",account,300).start();
        new DepositThread("存钱的小明",account,600).start();
        new DepositThread("存钱的小猪",account,200).start();
        new DepositThread("存钱的猫猫",account,1000).start();
    }
}

运行结果:

四 锁和同步

1.锁 Lock

(1)锁的原理

什么是锁?

在计算机科学中,锁(lock)或互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。锁旨在强制实施互斥排他、并发控制策略。

锁通常需要硬件支持才能有效实施。这种支持通常采取一个或多个原子指令的形式,如"test-and-set", "fetch-and-add" or "compare-and-swap"”。这些指令允许单个进程测试锁是否空闲,如果空闲,则通过单个原子操作获取锁。

关于锁的三个概念:

1.锁开销 lock overhead
锁占用内存空间、cpu初始化和销毁锁、获取和释放锁的时间。程序使用的锁越多,相应的锁开销就越大。

2.锁竞争 lock contention
一个进程或线程试图获取另一个进程或线程持有的锁,就会发生锁竞争。锁粒度越小,发生锁竞争的可能性就越小。

锁的一个重要属性是粒度锁粒度是衡量锁保护的数据量大小,通常选择粗粒度的锁(锁的数量少,每个锁保护大量的数据),在当单进程访问受保护的数据时锁开销小,但是当多个进程同时访问时性能很差。因为增大了锁的竞争。相反,使用细粒度的锁(锁数量多,每个锁保护少量的数据)增加了锁的开销但是减少了锁竞争。例如数据库中,锁的粒度有表锁、页锁、行锁、字段锁、字段的一部分锁。

3.死锁 deadlock
至少两个任务中的每一个都等待另一个任务持有的锁的情况。

Java中的锁主要用于保障并发线程情况下数据的一致性。在多线程编程中为了保障数据的一致性,我们通常在使用对象或者方法之前加锁,这时如果有其他线程也需要使用该对象或者该方法时,则首先需要获得锁,如果某个线程发现锁正在被其他线程使用,就会进入阻塞队列等待锁的释放,直到其他线程执行完成并释放锁,该线程才有机会再次获取锁进行操作。这样就保障了在同一时刻只有一个线程持有该对象的锁并修改对象,从而保障数据的安全。

锁主要提供了两种特性,互斥性和不可见性。因为锁的存在,某些操作对外界来说是黑箱进行的,只有锁的持有者才知道对变量进行了什么修改。

(2)锁的划分

Java锁具体可分为悲观锁/乐观锁自旋锁/适应性自旋锁偏向锁轻量级锁/重量级锁公平锁和非公平锁可重入锁/非可重入锁共享锁/排他锁。

1.乐观锁和悲观锁

乐观锁用乐观的想法来处理数据,即认为读多写少,遇到并发写的可能性低,因此它始终认为自己在读取数据时,别人不会修改这个数据,所以不会对这个数据加锁。但在更新时会判断在此期间别人有没有更新该数据,通常采用在写时先读出当前版本号然后再加锁的方法(比较当前版本与上一次的版本号,如果版本号一致,则重复进行读、比较、写操作)。Java中的乐观锁大部分基于CAS(Compare and Swap,比较和交换)算法实现(CAS是一种原子更新操作,在对数据操作之前首先会比较当前值和传入的值是否相同,如果相同就更新,否则不执行更新操作,可能进行报错或者自动重试)。

悲观锁则用悲观的思想来处理数据,即认为写多,遇到并发写的可能性高,所以它每次读取数据时都认为别人会修改这个数据,所以在每次读写时都会上锁,这样别人想读写这个数据的时候都会被阻塞直到拿到锁。Java中的synchronized关键字和Lock的实现类都是悲观锁。Java的悲观锁大部分基于AQS(Abstract Queued Synchronized,抽象的队列同步器)架构实现。AQS定义一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它,例如Synchroni zed, ReentrantLock,Semaphore, CountDownLatch等。该框架下的所会先尝试以CAS乐观锁取获取锁,如果获取不到,则会转为悲观锁。


应用场景:

悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。

      传统的关系型数据库使用了很多悲观锁机制如行锁,表锁都是操作之前先上锁;Java中的synchronized和ReentrantLock体现的就是悲观锁。

乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

      java.util.concurrent.atomic包下面的原子变量类就是基于CAS实现的乐观锁。 

 2.自旋锁和适应性自旋锁

自旋锁:阻塞或者唤醒一个Java的线程需要操作系统切换CPU状态来完成,这种状态的转换需要耗费处理器时间。如果同步代码块中的内容过于简单,很可能导致状态转换消耗的时间比用户代码执行的时间还要长。所以在短暂的等待之后就可以继续进行的线程,为了让线程等待一下,需要让线程进行自旋,在自旋完成之后,前面锁定了同步资源的线程已经释放了锁,那么当前线程就可以不需要阻塞便直接获取同步资源,从而避免了线程切换的开销。这就是自旋锁。

但是,线程在自旋时也会占用CPU资源,在线程长时间自旋获取不到锁时,将会产生CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用,所以一般需要设定一个最大的自旋等待时间,当线程执行时间超过最大自旋等待时间后,线程会退出自旋模式并释放其持有的锁。

自旋锁与非自旋锁的等待流程示意图如下。

自旋锁的优缺点:

优点: 阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。自旋锁就可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码来说性能大幅度提升。

缺点: 自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长或锁的竞争过于激烈时,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是 10 次,可以使用 -XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。

 

适应性自旋锁:自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

3. 偏向锁

无锁:

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。偏向锁的主要目的就是在同一个线程多次获取某个锁的情况下尽量减少轻量级锁的执行路径,(轻量级锁的获取及释放需要多次CAS原子操作,而偏向锁只需要在切换ThreadID时执行一次CAS原子操作,可以提高锁的运行效率。)在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

偏向锁在锁对象的对象头中有一个ThreadId字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即ThreadId字段为空,那么JVM让其持有偏向锁,并将ThreadId字段的值设置为该线程的ID。当下一次获取锁时,会判断当前线程的ID是否与锁对象的ThreadId一致,如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。

拓展:

偏向锁的获取:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
  5. 执行同步代码。

偏向锁的释放:

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

4.轻量级锁和重量级锁

轻量级锁:当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。轻量级锁在JDK1.6后提供,轻量级是相对于重量级而言的,它的核心设计是在没有多线程竞争的前提下,减少重量级锁的使用以及提高系统性能。轻量级锁适用于线程交替执行同步代码块(互斥操作)的情况。

重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。synchronized在内部基于监视器锁实现,监视器锁基于底层系统的MutexLock实现,synchronized属于重量级锁,重量级锁需要在用户态和核心态之间做切换,所以synchronized的运行效率不高。

 

拓展:

JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在JDK1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁。

5.公平锁和非公平锁

公平锁(Fair Lock):指在分配锁前检查是否有线程排队等待获取该锁,优先将锁分配给排队时间最长的线程。

非公平锁(Nonfair Lock):在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时再排到队尾等待。(有插队现象)

公平锁需要在多核的情况下维护一个锁线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低。另外,公平锁CPU唤醒阻塞线程的开销比非公平锁大,恢复一个挂起的线程到线程真正的去运行存在严重延迟。

假设线程A持有一个锁,并且线程B请求这个锁。由于锁被A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此B会再次尝试获取这个锁。与此同时,如果线程C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样就是一种双赢的局面:B获得锁的时刻并没有推迟,C更早的获得了锁,并且吞吐量也提高了。

当持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁。在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现。

6.可重入锁和非可重入锁

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁

不可重入锁:当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。不可重入锁,可能存在被当前线程所持有,且无法释放的死锁问题。

7.独享锁和共享锁

独享锁:该锁每一次只能被一个线程所持有,也叫排他锁,获得排他锁的线程既能读数据又能写数据

共享锁:该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。

独享锁与共享锁时通过AQS(Abstract Queued Synchronized,抽象的队列同步器)来实现的。

(Java锁的划分部分内容参考自:https://blog.csdn.net/weixin_41950473/article/details/92080488

(3)重量级锁-synchronized(线程同步)

它用于为Java对象、方法、代码块提供线程安全的操作。synchronized属于独占式的悲观锁,同时属于可重入锁。

在使用synchronized修饰对象时,同一时刻只能有一个线程对该对象进行访问。

在使用synchronized修饰方法和代码块时,同一时刻只能由一个线程对该方法体或代码块访问,其他线程只能等待当前想吃执行完毕并释放资源后才能访问该对象或执行同步代码块。

Java中的每个对象都有一个monitor对象加锁就是在竞争monitor对象。对代码块加锁是通过在前后分别加上monitorenter和monitorexit指令来实现的,对方法是否加锁是通过一个标记来判断的。

synchronized的作用范围如下:
1. synchronized作用于成员变量和非静态方法时,锁住的是对象实例,即this对象
2. synchronized作用于静态方法时,锁住的是Class实例,因为静态方法属于Class而不属于对象。
3. synchroni zed作用于一个代码块时,锁住的是所有代码块中配置的对象。
 

synchronized是一个重量级操作,需要调用操作系统的相关接口,性能较低,给线程加锁的时间有可能超过获取锁后具体逻辑代码的操作时间。JDK1. 6后对synchronized做了很多优化,引入了自旋,锁消除,锁粗化,轻量级锁及偏向锁等以提高锁的效率。

(4)ReentrantLock

ReentrantLock继承了Lock接口并实现了在接口中定义的方法,是一个可重入的独占锁(允许连续两次获得同一把锁,两次释放同一把锁。也就是说可以两次释放,两次加锁,但如果不对称,会抛出异常)。通过自定义队列同步器AQS来实现锁的获取与释放。

ReentrantLock支持公平与非公平锁的实现。

ReentrantLock不但提供了synchronized对锁的操作功能,还提供了诸如可响应中断锁,可轮询锁请求、定时锁等避免多线程死锁的方法。

ReentrantLock如何避免死锁?

1.响应中断。

在synchronized中如果有一个线程尝试获取一把锁,则其结果是要么获取锁继续执行,要么保持等待。而在ReenreantLock中还提供了可相应中断的可能——在等待锁的过程中,线程还可以取消对锁的请求。

使用lockInterruptibly()方法能够中断等待获取锁的线程。

2.可轮询锁。

通过boolean tryLock()获取锁,如果有可用锁,则获取该锁并返回true,如果无可用锁,则返回false。

3.定时锁。

通过boolean try(long time,TimeUnit unit)获取定时锁。如果在给定时间内得到了可用锁,且当前线程未被中断,则获取该锁并返回true。如果在给定的时间内没获取到可用锁,就禁用当前线程。

拓展->synchronized和ReentrantLock的区别:

共同点:
1.都用于控制多线程对共享对象的访问。
2.都是可重入锁。
3.都保证了可见性和互斥行。
不同点:
1. ReentrantLock显示获取和释放锁,必须在finally控制块中进行解锁;synchronized隐式获取和释放锁。
2. ReentrantLock可响应中断,可轮回,为处理锁提供了更多的灵活性。
3. ReentrantLock是API级别的,synchronized是JVM级别的。
4. ReentrantLock可以定义公平锁。
5.二者底层实现不同,synchronized是同步阻塞,采用的是悲观并发策略;Lock是同步非阻塞,采用的是乐观并发策略。
6. Lock是一个接口,而synchronized是Java中的关键字,synchronized是由内置的语言实现的。
7.通过Lock可以知道有没有成功获取锁,通过synchronized无法知道,
8. Lock可以通过分别定义读写锁提高多个线程读操作的效率。

(5)锁优化:

  • 减少锁的持有时间。只在有线程安全要求的程序上加锁,尽量减少同步代码块对锁的持有时间。
  • 减少锁粒度。使用分段锁机制,将单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并行度,减少同一个锁上的竞争。
  • 锁分离。根据不同的应用场景将锁的功能进行分离。如读写锁(ReadWriteLock),将锁分离为读锁和写锁,实现读读不互斥、读写互斥、写写互斥,保证了安全和性能。
  • 锁粗化。将关联性强的锁操作集中起来处理,以提高系统整体的效率。
  • 锁消除。消除一些不必要的锁来提高性能。

2.线程同步

(1)什么是同步

资源共享的两个原因是资源紧缺和共建需求。线程共享的CPU正是从资源紧缺的角度来考虑的;而多线程共享同一个变量,通常是从共建需求的维度来考量的。在多个线程对同一个变量进行写操作时,如果操作没有原子性,就很可能产生脏数据。

所谓原子性就是指不可分割的一系列操作指令,在执行完毕前不会被任何其他操作中断,要么全部执行,要么全部不执行。如果每个线程的操作都是原子操作,那就不存在线程同步问题了。一些看起来简单的操作其实都不是原子操作,比如i++这个操作,需要分三步进行(ILOAD-ILNC-ISTORE),有如更复杂的CAS操作却具有原子性。

线程同步的现象在生活中也很常见。比如乘客在火车站排队打车,每个人都是一个线程,管理员每次放10个人进来,为了保证安全,等全部离开后,再放下一批人进来。如果没有这种协调机制,场面一定会混乱不堪,人们会发生争抢,存在严重的安全隐患。

计算机中的线程同步,就是线程之间按某种机制协调先后次序执行,当有一个线程在对内存进行操作时,其他线程就都不可以对这个内存地址进行操作,直到该线程完成操作。

实现线程同步的方法有很多,如同步方法、锁、阻塞队列等。

(2)volatile关键字

volatile的英文本意是“挥发的、不稳定的”,这里延伸意义为敏感的。当使用volatile修饰变量时,意味着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。

volatile解决的是多线程共享变量的可见性问题(一个变量被多个线程所共享),类似于synchronized,但不具备synchronized的互斥性。对volatile变量的操作并非都具有原子性。

需要注意的是,volatile只是轻量级的线程操作可见方式,并非同步方式。如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景,使用volatile修饰变量则十分合适。volatile一写多读最典型的应用是集合中的CopyOnWriteArrayList(COW),它在修改数据时会把整个集合的数据全部复制出来,对写操作加锁,修改完成后,再用setArray()把array指向新的集合。使用volatile可以使读写线程尽快的感知array的修改,不进行指令重排,操作后即对其他线程可见。

在实际应用中,如果不确定共享变量是否会被多个线程并发写,保险的做法是使用同步代码块来实现线程同步。

因为所有的操作都要同步给内存变量,所以volatile一定会使线程的执行素的变慢,使用时要谨慎。

(3)信号量同步

信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。

Semaphore是一种基于计数的信号量,用于控制同时访问某些资源的线程个数。通过调用acquire()获得一个许可,如果没有许可,则等待,在许可使用完毕后通过release()释放该许可,以便其他线程使用。(Semaphore对锁的申请和释放同ReentrantLock类似,通过acquire和release进行获取和释放资源。其中acquire和ReentrantLock中的lockInterruotibly方法一样,是一种可响应的中断锁,也就是说在等待许可信号资源的过程中可以被Thread. interrupt方法中断而取消对许可信号的申请。)

在定义信号量对象时可以设定一个阈值,基于该阈值,多个线程竞争获取许可信号,线程竞争到许可信号后开始执行具体业务逻辑,业务逻辑在执行完成后释放该许可信号。在许可信号的竞争队列超过阈值后,新加入的申请许可信号的线程将被阻塞,直到有其他许可信号被释放。
 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值