Java多线程

线程与进程

进程
  几乎所有的操作系统都支持进程的概念,所有运行中的任务通常对应一个进程( Process)。当一个程序进入内存运行时,即变成一个进程。进程是处于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位。进程特征:

  1. 独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间
  2. 动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,这些概念在程序中都是不具备的
  3. 并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响

线程
  线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

进程为什么开销大?有哪些开销
  进程切换比线程切换开销大是因为进程切换时要切页表,而且往往伴随着页调度,因为进程的数据段代码段要换出去,以便把将要执行的进程的内容换进来。页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个cache就是TLB。当进程切换后页表也要进行切换,页表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢。而且线程只需要保存线程的上下文(相关PC寄存器状态和栈空间)就好了,动作很小。

  无论是在多核还是单核系统中,一个CPU看上去都像是在并发的执行多个进程,这是通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换。操作系统保持跟踪进程运行所需的所有状态信息,这种状态,也就是上下文,它包括许多信息,例如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。
  当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文,恢复新进程的上下文,然后将控制权传递到新进程,新进程就会从上次停止的地方开始

上下文切换
内核为每一个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。包括以下内容:通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、各种内核数据结构:比如描绘地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

进程切换
  系统中的每个程序都是运行在某个进程的上下文中的。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在存储器中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。所以进程切换就是上下文切换。

进程切换和线程切换有什么区别?
  虚拟内存是操作系统为每个进程提供的一种抽象,每个进程都有属于自己的、私有的、地址连续的虚拟内存,当然最终进程的数据及代码必然要放到物理内存上,那么必须有某种机制能记住虚拟地址空间中的某个数据被放到了哪个物理内存地址上,这就是所谓的地址空间映射,那么操作系统是通过页表记住这种映射关系的。每个进程都有自己的虚拟地址空间,进程内的所有线程共享进程的虚拟地址空间。
进程切换和线程切换的区别:最主要的一个区别在于进程切换涉及虚拟地址空间的切换而线程不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。
  为什么虚拟地址空间切换会比较耗时呢? 进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个cache就是TLB(translation Lookaside Buffer)。由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致TLB失效,因为线程无需切换地址空间,因此线程切换要比较进程切换快。

多进程优点:
1、每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系
2、通过增加CPU,就可以容易扩充性能
3、可以尽量减少线程加锁/解锁的影响,极大提高性能
4、每个子进程都有2GB地址空间和相关资源,总体能够达到的性能上限非常大
多进程缺点:
1、逻辑控制复杂,需要和主程序交互
2、需要跨进程边界,如果有大数据量传送,就不太好,适合小数据量传送、密集运算
3、多进程调度开销比较大

多线程的优点:
1、程序逻辑和控制方式简单
2、所有线程可以直接共享内存和变量等
3、线程方式消耗的总资源比进程方式好
多线程缺点:
1、每个线程与主程序共用地址空间,受限于2GB地址空间
2、线程之间的同步和加锁控制比较麻烦
3、一个线程的崩溃可能影响到整个程序的稳定性
4、线程能够提高的总性能有限,到达一定的线程数程度后,即使再增加CPU也无法提高性能

线程的局部变量

栈和堆的区别是:

  • 栈是系统根据变量大小自动分配空间的
  • 堆是用new,malloc等手动分配空间的

  每当启动一个新线程时,java虚拟机都会为它分配一个java栈,java栈上的所有数据都是此线程私有的。任何线程都不能访问另一个线程的栈数据,因此不需要考虑多线程情况下栈数据的访问同步问题。当一个线程调用一个方法的时候,方法的局部变量保存在调用线程的java栈的桢中,只有一个线程能访问那些局部变量,即调用方法的线程。

并发和并行
并发:当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行
并行:当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行

1、多核CPU同时可以执行多个线程,有几个核就能执行几个线程
2、这些线程分属不同进程的话就是执行了多个进程,因此多核CPU可以同时执行多个进程
3、多核CPU和多个CPU运行机制相同也不同,单就运行多线程任务来讲原理是差不多的

多线程

  多线程就是几乎同时执行多个线程(在某一个时间点上永远都只能是一个线程),几乎同时是因为实际上多线程程序中的多个线程实际上是一个线程执行一会然后其他的线程再执行,并不是所谓的同时执行。多线程优点:

  1. 并发提升程序执行效率
  2. 提升CPU利用率

使用多线程

多线程的创建

【1】继承Thread类

  1. 定义Thread类的之类,并重写run方法,该run方法的方法体就代表了线程需要执行的任务
  2. 创建Thread类的实例
  3. 调用线程的start()方法来启动线程

【2】实现Runnable接口

  1. 定义Runnable接口的实现类,并重写该接口的run方法,该run方法同样是线程需要执行的任务
  2. 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象

【3】使用Callable和Future创建线程
  从Java5开始,Java提供了Callable接口,该接口提供了一个call()方法作为线程执行体。创建并启动有返回值的线程的步骤如下:

  1. 创建 Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值,再创建 Callable实现类的实例。从Java8开始,可以直接使用 Lambda表达式创建 Callable对象
  2. 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call方法的返回值
  3. 使用FutureTask对象作为Thread对象的target创建并启动新线程
  4. 通过FutureTask的get()方法获得子线程执行结束后的返回值
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class ThirdThread {
    public static void main(String[] args) {
        //ThirdThread rt=new ThirdThread();
        FutureTask<Integer> task=new FutureTask<Integer>((Callable<Integer>)()->{
            int i=0;
            for(;i<100;i++) {
                System.out.println(Thread.currentThread().getName()+"的循环变量i"+i);
            }
            return i;
        }) ;
        for(int i=0;i<100;i++) {
            System.out.println(Thread.currentThread().getName()+"的循环变量i为"+i);
            if(i==20) {
                new Thread(task,"有返回值的线程").start();;
            }
        }
        try {
            System.out.println("子线程的返回值"+task.get());
        }catch(Exception e) {
            e.printStackTrace();
        }
    }
}

【4】使用线程池启动多线程
  通过Executor 的工具类可以创建三种类型的普通线程池,例如FixThreadPool(int n); 固定大小的线程池。ExecutorService的submit与execute方法都能执行任务,但在使用过程,发现其对待run方法抛出的异常处理方式不一样。两者执行任务最后都会通过Executor的execute方法来执行,但对于submit,会将runnable物件包装成FutureTask< Object>,其run方法会捕捉被包装的Runnable Object的run方法抛出的Throwable异常,待submit方法所返回的的Future Object调用get方法时,将执行任务时捕获的Throwable Object包装成java.util.concurrent.ExecutionException来抛出。
而对于execute方法,则会直接抛出异常,该异常不能被捕获,想要在出现异常时做些处理,可以实现Thread.UncaughtExceptionHandler接口

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
	public static void main(String[] args) {
		ExecutorService ex=Executors.newFixedThreadPool(5);
		for(int i=0;i<5;i++) {
			ex.submit(new Runnable() {
				@Override
				public void run() {
					for(int j=0;j<10;j++) {
						System.out.println(Thread.currentThread().getName()+j);
					}
				}
			});
		}
		ex.shutdown();
	}	
}

补充:Runnable和Callable的区别
1)Runnable提供run方法,无法通过throws抛出异常,所有CheckedException必须在run方法内部处理。Callable提供call方法,直接抛出Exception异常
2)Runnable的run方法无返回值,Callable的call方法提供返回值用来表示任务运行的结果
3)Runnable可以作为Thread构造器的参数,通过开启新的线程来执行,也可以通过线程池来执行。而Callable通过FutureTask类来包装,使用FutureTask对象作为Thread对象的target创建并启动新线程

创建线程的三种方式

采用Runnable、Callable接口的方式创建多线程
优点:

  1. 线程类只是实现了 Runnable接口或 Callable接口,还可以继承其他类
  2. 在这种方式下,多个线程可以共享同一个 target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想

缺点:编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法

采用继承 Thread类的方式创建多线程
优点:编写简单,如果需要访问当前线程,则无须使用 Thread.current Thread()方法,直接使用this即可获得当前线程
缺点:因为线程已经继承了Thread类,所以不能再继承其他类

线程的生命周期

初始状态
  线程的实现有三种方式,一是继承Thread类,二是实现Runnable接口,三是实现Callable接口,当new了这个对象之后,线程就进入了初始化状态。

可运行状态
  该状态的线程位于“可运行线程池”中,只等待获取CPU的使用权。
1、线程被new出来,调用start()方法,此线程进入就绪状态
2、当前线程sleep()方法结束,其他线程join()方法,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态
3、当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态
4、锁池里的线程拿到对象锁后,进入就绪状态

运行状态
  如果处于就绪状态的线程获取了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态。

阻塞状态
  阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。阻塞的情况分三种:
【1】等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒
【2】同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中
【3】其他阻塞:运行的线程执行sleep()方法、join()方法或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态

死亡状态
当线程的run()方法完成时,或者主线程的main()方法完成时,线程正常结束

线程状态转化图:
在这里插入图片描述

控制线程

【1】join线程
  Thread提供了让一个线程等待另一个线程完成的方法——join方法。当在某个程序执行流中调用其直到被 join方法加入的join线程执行完为止

public class JoinThread extends Thread {
    //提供一个有参数的构造器,用于设置该线程的名字
    public JoinThread(String name) {
        super(name);
    }
    //重写run方法,定义线程体
    public void run() {
        for(int i=0;i<10;i++) {
            System.out.println(getName()+" "+i);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //启动子线程
        new JoinThread("新线程").start();
        for(int i=0;i<10;i++) {
            if(i==5) {
                JoinThread jt=new JoinThread("被join的线程");
                jt.start();
                //main线程调用了jt线程的join方法,main线程
                //必须等jt执行结束才会向下执行
                jt.join();
            }
            System.out.println(Thread.currentThread().getName()+" "+i);
        }
    }
}

【2】线程睡眠
  如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用 Thread类的静态 sleep方法来实现。 sleep方法有两种重载形式(1)static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态;(2)static void sleep(long millis,int nanos):让当前正在执行的线程暂停millis毫秒加上nanos毫微秒,并进入阻塞状态

import java.util.Date;
public class SleepTest {
    public static void main(String[] args) throws InterruptedException {
        for(int i=0;i<10;i++) {
            System.out.println("当前时间"+new Date());
            Thread.sleep(1000);
        }
    }
}

1、sleep是Thread中的方法;wait是Object中的方法,因为synchronized中的这把锁可以是任意对象,所以任意对象都可以调用wait()和notify()
2、sleep方法不会释放锁,但是wait会释放,而且会加入到等待队列中
3、wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,因为要对持有监视器(锁)的线程操作。所以要使用在同步中,因为只有同步 才具有锁。而sleep可以在任何地方使用
4、sleep不需要被唤醒,但是wait需要
5、sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常

join()与sleep()区别:
线程除了join方法之外还有sleep(long)方法也是可以实现等待;
与sleep()的区别是:由于join的内部实现是wait(),所以使用join()方法是会释放锁的,那么其他线程就可以调用此线程的同步方法了,
而sleep(long)方法具有不是放锁的特点,因此线程会一直等待下去,直到任务完成,才会释放锁。

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

为什么wait方法在object类中,sleep方法在Thread类中?
  1.在java的内置锁机制中,每个对象都可以成为锁,而Object类是所有类的一个父类,把这些方法放在Object中,则java中的所有对象都可以去调用这些方法了。
  2.一个线程可以拥有多个对象锁,wait,notify,notifyAll跟对象锁之间是有一个绑定关系的,这样jvm很容易就知道应该从哪个对象锁的等待池中去唤醒线程。如果用Thread.wait(),Thread.notify(),Thread.notifyAll()来调用,虚拟机根本就不知道需要操作的对象锁是哪一个。

【3】yield()的用法
  Thread.yield()方法的作用:暂停当前正在执行的线程,并执行其他线程。(可能没有效果)
  yield()让当前正在运行的线程回到可运行状态,以允许具有相同优先级的其他线程获得运行的机会。因此,使用yield()的目的是让具有相同优先级的线程之间能够适当的轮换执行。但是,实际中无法保证yield()达到让步的目的,因为,让步的线程可能被线程调度程序再次选中。

线程安全

  多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。线程安全在三个方面体现:
1.原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized)
2.可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile)
3.有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)

原子性—atomic

atomic类

JDK里面提供了很多atomic类,它们是通过CAS完成原子性。

对于AtomicInteger的incrementAndGet()方法:
在这里插入图片描述
再看getAndAddInt()方法:
在这里插入图片描述
这里面调用了compareAndSwapInt()方法:
在这里插入图片描述
它是native修饰的,代表是java底层的方法,不是通过java实现的 。对于getAndAddInt(),传来第一个值是当前的一个对象 ,比如是count.incrementAndGet(),那么在getAndAddInt()中,var1就是count,而var2第二个值是当前的值,比如想执行的是2+1=3操作,那么第二个参数是2,第三个参数是1 。变量5(var5)是调用底层的方法而得到的底层当前的值,如果没有别的线程过来处理count变量的时候,那么它正常返回值是2。因此传到compareAndSwapInt方法里的参数是(count对象,当前值2,当前从底层传过来的2,从底层取出来的值加上改变量var4)。

compareAndSwapInt()希望达到的目标是对于var1对象,如果当前的值var2和底层的值var5相等,那么把它更新成后面的值(var5+var4) compareAndSwapInt核心就是CAS核心。

对于AtomicStampedReference。关于CAS有一个ABA问题:开始是A,后来改为B,现在又改为A。解决办法就是:每次变量改变的时候,把变量的版本号加1。这就用到了AtomicStampedReference。AtomicStampedReference里的compareAndSet()实现:
在这里插入图片描述
而在AtomicInteger里compareAndSet()实现:
在这里插入图片描述
可以看到AtomicStampedReference里的compareAndSet()中多了 一个stamp比较(也就是版本),这个值是由每次更新时来维护的。

synchronized

  synchronized是一种同步锁,通过锁实现原子操作。JDK提供锁分两种:一种是synchronized,依赖JVM实现锁,因此在这个关键字作用对象的作用范围内是同一时刻只能有一个线程进行操作;另一种是LOCK,是JDK提供的代码层面的锁,依赖CPU指令,代表性的是ReentrantLock。synchronized修饰的对象有四种:
(1)修饰代码块,作用于调用的对象
(2)修饰方法,作用于调用的对象
(3)修饰静态方法,作用于所有对象
(4)修饰类,作用于所有对象

可见性

volatile

  volatile的可见性是通过内存屏障和禁止重排序实现的。volatile会在写操作时,会在写操作后加一条store屏障指令,将本地内存中的共享变量值刷新到主内存。volatile在进行读操作时,会在读操作前加一条load指令,从内存中读取共享变量。volatile并不能保证线程安全。

有序性

  有序性是指,在JMM中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。可以通过volatile、synchronized、lock保证有序性。
  另外,JMM具有先天的有序性,即不需要通过任何手段就可以得到保证的有序性。这称为happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性。虚拟机可以随意地对它们进行重排序。

happens-before原则:
1.程序次序规则:在一个单独的线程中,按照程序代码书写的顺序执行
2.锁定规则:一个unlock操作happen—before后面对同一个锁的lock操作
3.volatile变量规则:对一个volatile变量的写操作happen—before后面对该变量的读操作
4.线程启动规则:Thread对象的start()方法happen—before此线程的每一个动作
5.线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行
6.线程中断规则:对线程interrupt()方法的调用happen—before发生于被中断线程的代码检测到中断时事件的发生
7.对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始
8.传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C

进程同步机制

消息传递,信号量和管程

【1】信号量
  用于进程间传递信号的一个整数值。在信号量上只有三种操作可以进行:初始化,P操作和V操作,这三种操作都是原子操作。P操作(递减操作)可以用于阻塞一个进程,V操作(增加操作)可以用于解除阻塞一个进程。
  基本原理是两个或多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一位置停止,直到它接收到一个特定的信号。该信号即为信号量s。为通过信号量s传送信号,进程可执行原语semSignal(s);为通过信号量s接收信号,进程可执行原语semWait(s);如果相应的信号仍然没有发送,则进程被阻塞,直到发送完为止。可把信号量视为一个具有整数值的变量,在它之上定义三个操作:
1、一个信号量可以初始化为非负数
2、semWait操作使信号量s减1.若值为负数,则执行semWait的进程被阻塞。否则进程继续执行。
3、semSignal操作使信号量加1,若值大于或等于零,则被semWait操作阻塞的进程被解除阻塞。

【2】管程
  管程是由一个或多个过程、一个初始化序列和局部数据组成的软件模块,其主要特点如下:
1、局部数据变量只能被管程的过程访问,任何外部过程都不能访问。
2、一个进程通过调用管程的一个过程进入管程。
3、在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。
  管程通过使用条件变量提供对同步的支持,这些条件变量包含在管程中,并且只有在管程中才能被访问。有两个函数可以操作条件变量:
1、cwait( c):调用进程的执行在条件c上阻塞,管程现在可被另一个进程使用。
2、csignal( c):恢复执行在cwait之后因为某些条件而阻塞的进程。如果有多个这样的进程,选择其中一个;如果没有这样的进程,什么以不做。

【3】消息传递
  消息传递的实际功能以一对原语的形式提供:send(destination,message)、receive(source,message)
  这是进程间进程消息传递所需要的最小操作集。一个进程以消息的形式给另一个指定的目标进程发送消息;进程通过执行receive原语接收消息,receive原语中指明发送消息的源进程和消息。

线程同步机制

线程同步主要用于协调对临界资源的访问,线程同步有4种机制:临界区、互斥量、事件、信号量。主要区别在于:

  • 适用范围:临界区在用户模式下,不会发生用户态到内核态的切换,只能用于同进程内线程间同步。其他会导致用户态到- 内核态的切换,利用内核对象实现,可用于不同进程间的线程同步
  • 性能:临界区性能较好,一般只需数个CPU周期。其他机制性能相对较差,一般需要数十个CPU周期;临界区不支持等待时间,为了获取临界资源,需要不断轮询(死循环或Sleep一段时间后继续查询),其他机制内核负责触发,在对临界资源竞争较少的情况下临界区的性能表现较好,在对临界区资源竞争激烈的情况下临界区有额外的CPU损耗(死循环方式下)或响应时间延迟(Sleep方式下)
  • 应用范围:可用临界区机制实现同进程内的互斥量、事件、信号量功能;互斥量实现了互斥使用临界资源;事件实现单生产多消费(同时只能一个消费)功能;信号量实现多生产多消费功能

各同步机制功能说明如下:
临界区
临界区是一段独占对某些共享资源访问的代码,在任意时刻只允许一个线程对共享资源进行访问。如果有多个线程试图同时访问临界区,那么在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的。尽量使用用户模式下的临界区机制,避免使用需要用户态到内核态切换的同步机制。

互斥量
功能上跟临界区类似,不过可用于不同进程间的线程同步。

事件
触发重置事件对象,那么等待的所有线程中将只有一个线程能唤醒,并同时自动的将此事件对象设置为无信号的;它能够确保一个线程独占对一个资源的访问。和互斥量的区别在于多了一个前置条件判定。

信号量
信号量用于限制对临界资源的访问数量,保证了消费数量不会大于生产数量。

Java提供的线程同步机制包括锁、volatile关键字、final关键字、static关键字和一些相关的API,如Object.wait( )/.notify( )等

线程同步和互斥的区别:
  互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。同步是指在互斥的基础上,通过其它机制实现访问者对资源的有序访问。
  同步关系则是多个线程彼此合作,通过一定的逻辑关系来共同完成一个任务。一般来说,同步关系中往往包含互斥,同时对临界区的资源会按照某种逻辑顺序进行访问。如先生产后使用。

使用synchronized关键字

  由于每个java对象都有一个内置锁,用synchronized修饰方法或者代码块时,内置锁会保护整个方法或代码块,要想执行这个方法或者代码块必须获得其内置锁,运行时会加上内置锁,当运行结束时,内置锁会打开。

1.修饰方法
因为synchronized是不能锁住不同对象的线程的,只能锁住同一个对象的线程,也就是说锁住的是方法所属的主体对象自身
2.修饰代码块
3.修饰静态方法
public static synchronized void anotherMethod() {
// do something
}
对于静态方法,锁住的不是这个类的对象,也不是也不是这个类自身,而是这个类所属的java.lang.Class类型的对象

wait与notify

  • wait(),使一个线程处于等待状态,并释放所持对象的锁,与sleep不同,sleep不会释放对象锁
  • notify(),唤醒一个处于阻塞状态的线程,进入就绪态,并加锁,只能唤醒一个线程,但不能确切知道唤醒哪一个,由JVM决定,不是按优先级。其实不是对对象锁的唤醒,是告诉调用wait方法的线程可以去竞争对象锁了。wait和notify必须在synchronized代码块中调用
  • notifyAll(),唤醒所有处于阻塞状态的线程,并不是给他们加锁,而是让他们处于竞争。

为什么wait和notify要在synchronized代码块中使用
调用wait()就是释放锁,释放锁的前提是必须要先获得锁,先获得锁才能释放锁,释放锁后进入等待队列。
notify(),notifyAll()是将锁交给含有wait()方法的线程,让其继续执行下去(本质是让处于阻塞队列的线程进入等待队列竞争锁)

volatile关键字

  volatile是一种轻量锁,锁住的是变量。使用volatile修饰数据,该修饰词是对域变量的访问提供了以这种防锁机制,相当于告诉虚拟机,该域的变量可能被更改。因此每次使用该域都要重新计算,而不是从寄存器中取出数据。从而实现线程的同步。
  不同线程在执行时,数据都是从主内存中取得的,每个线程自身都有一个工作内存。线程读入和写入数据的过程如下:先从主内存中读取数据,放入工作内存,传递到线程中使用,在修改数据时,原路返回,经工作内存再写入到主内存中。这样就会有问题了,当两个线程1先把修改的数据经过工作内存写回到主内存的过程中,线程二读取主内存中的数据了,这样数据就出现了不一致性,我们把这种情况叫做线程之间不可见性。volatile关键字就是用来解决这种不可见问题的,它是怎么实现的呢?

  • 使用volatile修饰的变量在被一个线程修改后,直接将数据写回到主内存,跳过了工作内存
  • 使用volatile修饰的变量,在被线程1修改后,线程二中的该变量就被视为无效
  • 线程2中数据无效了,在使用的时候就必须重新回主内存中读取该数据

Lock

  前面的synchronized加锁,只有加锁和释放所锁,在JDK5后出现了新的加锁方法,使用Lock,包含比synchronized更多的加锁功能。ReentrantLock类是实现了Lock接口的锁,其中lock()方法为打开锁,unlock()方法为关闭锁。两者区别:

  • synchronized是java的关键字,是java的内置特性,是基于JVM层面的,而Lock是接口,是基于javaJDK层面的,通过这个接口可以实现同步访问
  • synchronized是不需要手动释放锁的,在代码执行完后,系统会让线程自动释放锁,但是Lock要手动解锁,如果不手动解锁,会出现死锁现象

ThreadLocal类

  使用ThreadLocal管理变量,每一个使用该变量的线程都获得该变量的副本,各个副本之间相互独立,每个线程都可以随意修改变量副本,而不会对其他线程造成影响。不过该方法和同步机制不相同。该类管理的变量在每个线程中都有自己的副本,副本之间相互独立,因此获得的结果和其他不同。

线程池

  系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。
  与数据库连接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个 Runnable对象或 Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个Runnable对象的run()或call()方法。创建线程池的几个常用的方法:

  1. newSingleThreadExecutor
    创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行
  2. newFixedThreadPool
    创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程
  3. newCachedThreadPool
    创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小
  4. newScheduledThreadPool
    创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求
    更详细内容参见线程池以及拒绝策略

JVM进程启动会启动哪些线程

  每当使用java命令执行一个带main方法的类时,就会启动JVM(应用程序),实际上就是在操作系统中启动一个JVM进程,JVM启动时,必然会创建以下5个线程:

1-main                          主线程,执行我们指定的启动类的main方法
2-Reference Handler             处理引用的线程 
3-Finalizer                     调用对象的finalize方法的线程,就是垃圾回收的线程 
4-Signal Dispatcher             分发处理发送给JVM信号的线程  
5-Attach Listener               负责接收外部的命令的线程
  • Attach Listener :该线程是负责接收到外部的命令,执行该命令,并且把结果返回给发送者。通常我们会用一些命令去要求jvm给我们一些反馈信息,如:java -version、jmap、jstack等等。如果该线程在jvm启动的时候没有初始化,那么,则会在用户第一次执行jvm命令时,得到启动
  • signal dispather: 前面我们提到第一个Attach Listener线程的职责是接收外部jvm命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果。signal dispather线程也是在第一次接收外部jvm命令时,进行初始化工作
  • Finalizer: JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收
  • Reference Handler :它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题
  • main:主线程,用于执行我们编写的java程序的main方法

让线程按顺序执行

[1] 使用线程的join方法
[2] 使用主线程的join方法
[3] 使用线程的wait方法
[4] 使用单线程化线程池(newSingleThreadExecutor)方法
[5] 使用线程的Condition(条件变量)方法
[6] 使用线程的CountDownLatch(倒计数)方法
[7] 使用线程的CyclicBarrier(回环栅栏)方法
[8] 使用线程的Semaphore(信号量)方法

使用线程的join方法
join():是Theard的方法,作用是调用线程需等待该join()线程执行完成后,才能继续用下运行。
应用场景:当一个线程必须等待另一个线程执行完毕才能执行时可以使用join方法。

使用主线程的join方法
这里是在主线程中使用join()来实现对线程的阻塞。

使用线程的wait方法
wait():是Object的方法,作用是让当前线程进入等待状态,同时,wait()也会让当前线程释放它所持有的锁。“直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法”,当前线程被唤醒(进入“就绪状态”)

notify()和notifyAll():是Object的方法,作用则是唤醒当前对象上的等待线程;notify()是唤醒单个线程,而notifyAll()是唤醒所有的线程。

wait(long timeout):让当前线程处于“等待(阻塞)状态”,“直到其他线程调用此对象的notify()方法或 notifyAll() 方法,或者超过指定的时间量”,当前线程被唤醒(进入“就绪状态”)。

应用场景:Java实现生产者消费者的方式。

使用单线程化线程池(newSingleThreadExecutor)方法
单线程化线程池(newSingleThreadExecutor):优点,串行执行所有任务

submit():提交任务

shutdown():方法用来关闭线程池,拒绝新任务

应用场景: 串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行

使用线程的Condition(条件变量)方法
Condition(条件变量):通常与一个锁关联。需要在多个Contidion中共享一个锁时,可以传递一个Lock/RLock实例给构造方法,否则它将自己生成一个RLock实例。

  • Condition中await()方法类似于Object类中的wait()方法
  • Condition中await(long time,TimeUnit unit)方法类似于Object类中的wait(long time)方法
  • Condition中signal()方法类似于Object类中的notify()方法
  • Condition中signalAll()方法类似于Object类中的notifyAll()方法

应用场景:Condition是一个多线程间协调通信的工具类,使得某个或者某些线程一起等待某个条件(Condition),只有当该条件具备( signal 或者 signalAll方法被带调用)时 ,这些等待线程才会被唤醒,从而重新争夺锁。

使用线程的CountDownLatch(倒计数)方法
CountDownLatch:位于java.util.concurrent包下,利用它可以实现类似计数器的功能。

应用场景:比如有一个任务C,它要等待其他任务A,B执行完毕之后才能执行,此时就可以利用CountDownLatch来实现这种功能了。

使用CyclicBarrier(回环栅栏)实现线程按顺序运行
CyclicBarrier(回环栅栏):通过它可以实现让一组线程等待至某个状态之后再全部同时执行。叫做回环是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用。暂且把这个状态就叫做barrier,当调用await()方法之后,线程就处于barrier了。

使用Sephmore(信号量)实现线程按顺序运行
Sephmore(信号量):Semaphore是一个计数信号量,从概念上将,Semaphore包含一组许可证,如果有需要的话,每个acquire()方法都会阻塞,直到获取一个可用的许可证,每个release()方法都会释放持有许可证的线程,并且归还Semaphore一个可用的许可证。然而,实际上并没有真实的许可证对象供线程使用,Semaphore只是对可用的数量进行管理维护。

acquire():当前线程尝试去阻塞的获取1个许可证,此过程是阻塞的,当前线程获取了1个可用的许可证,则会停止等待,继续执行。

release():当前线程释放1个可用的许可证。

应用场景:Semaphore可以用来做流量分流,特别是对公共资源有限的场景,比如数据库连接。假设有这个的需求,读取几万个文件的数据到数据库中,由于文件读取是IO密集型任务,可以启动几十个线程并发读取,但是数据库连接数只有10个,这时就必须控制最多只有10个线程能够拿到数据库连接进行操作。这个时候,就可以使用Semaphore做流量控制。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值