【Java】多线程与并发学习笔记

实现多线程的方法

查看Thread类的源码,可以发现它实现了Runnable接口,然后在自己的run方法中调用了Runnable的run方法。这里其实就是静态代理这一设计模式,Thread是代理角色,而Runnable则是真实角色。在Thread的start方法中,会为我们开辟新的线程并执行其run方法,而Thread的run方法会调用它内部持有引用的Runnable的run方法。因此,我们有以下几种方法开辟新线程:

1. 重写Thread的run方法

通过重写Thread的run方法,不再传给它Runnable,通过其Runnable的run方法执行,而是在的run方法中直接执行相应的操作。

2. 通过Runnable的run方法

通过创建一个Runnable,通过静态代理的设计,将Runnable对象传给Thread,再通过Thread开辟新线程并做相应操作

3. 通过Callable接口

通过Callable<T>接口实现多线程,比较繁琐,优点是有返回值。

创建一个类实现Callable<T>接口,其中泛型为返回的值的类型、然后实现Callable的call方法。

借助执行调度工具ExecutorService,创建对应类型线程池。获取Future对象(submit方法中的参数是具体实现类对象)。然后通过Future的get方法即可获取到返回值(此处要做异常处理,将异常throws了)。

get这里会等待线程中步骤执行完然后获取数据。

调用ExecutorService的shutdown方法停止服务

线程的状态

线程有五大状态,分别是

  • new:新建状态
  • Runnable:就绪状态
  • Running:运行状态
  • Blocked:阻塞状态
  • Dead:死亡状态

img

上面的图是线程的状态的示意图。

当我们创建线程后,它便进入了new(新建)状态。

在我们调用了它的start方法后,它便进入了Runnable(就绪)状态。

经过CPU的调度,它便会进入Running(运行)状态。

如果在运行后执行完毕,这个线程便会进入Dead(死亡)状态。

如果在运行过程中一些事情导致了线程的阻塞,它就会进入Blocked(阻塞)状态。阻塞解除后它便会重新进入Runnable(就绪状态)

线程的停止

1. 自然终止

当线程体自然执行完毕后,线程便会自然终止。

2. 外部干涉

外部干涉非常简单,比如下面这种方法

线程类中定义线程体使用标志

线程体内使用flag

提供对外方法改变该标识

外部调用

3. Thread的stop方法

这个方法已经被弃用,并且臭名昭著。从他的字面意思上我们可以知道他貌似可以停止一个线程。但实际上它并非是真正意义上的停止线程,而且停止线程还会引来一些其他的麻烦事。

调用Thread.stop()方法是不安全的,这是因为当调用Thread.stop()方法时,会发生下面两件事:

  1. 即刻抛出ThreadDeath异常,在线程的run()方法内,任何一点都有可能抛出ThreadDeath异常,包括在catch或finally语句中。
  2. 会释放该线程所持有的所有的锁,而这种释放是不可控制的,非预期的。

当线程抛出ThreadDeath异常时,会导致该线程的run()方法突然返回来达到停止该线程的目的。ThreadDetath异常可以在该线程run()方法的任意一个执行点抛出

但是,线程的stop()方法一经调用线程的run()方法就会即刻返回吗?

我们用下面的例子来测试一下

会发现,程序运行的结果如图:

running..99994

running..99995

running..99996

running..99997

running..99998

running..99999

可以看到,调用了stop方法之后,线程并没有停止,而是将run方法执行完 。这就十分诡异了。根据SUN的文档,原则上只要一调用thread.stop()方法,那么线程就会立即停止,并抛出ThreadDeath异常。

查看Thread的源代码后发现 ,Thread.stop(Throwable obj)方法是同步的 ,而我们工作线程的run()方法也是同步。会导致主线程和工作线程共同争用同一个锁(工作线程对象本身) 。 由于工作线程在启动后就先获得了锁,所以无论如何,当主线程在调用stop()时,它必须要等到工作线程的run()方法执行结束后才能进行,结果导致了上述奇怪的现象。

因此,不要用stop方法停止线程

线程的阻塞

1. join 合并线程

join可以理解为合并的意思,将多条线程合并为一条。下面是join的几个重载

  • join():等待该线程终止
  • join(long millis):等待该线程终止的时间最长为millis毫秒
  • join(long millis, int nanos):等待该线程终止的时间最长为millis毫秒+nanos纳秒

我们可以通过下面的例子来看看join的效果

查看运行结果:

查看运行结果,会发现。在执行join之前,main线程与 t 线程是多线程同时执行的。而当执行了t的join方法之后,等到 t 线程执行完后,main线程才继续执行。也就是在 t 线程执行的过程中,main线程被阻塞了。等到 t 线程执行完,main线程才继续执行。

2. yield 暂停线程

yield方法可以实现暂停当前线程,执行其他的线程。

它是一个static方法,暂停的具体线程取决于它执行的地方。

3. sleep 休眠线程

sleep方法可以让当前正在执行的线程进行休眠一定秒数。

  • sleep(long millis):让当前线程休眠millis毫秒
  • join(long millis, int nanos):让当前线程休眠millis毫秒+nanos纳秒

每一个对象都有一把锁。当该线程休眠的时候,不会释放锁。

sleep可以有以下的作用:

线程的优先级

我们可以为线程通过setPriority方法设置优先级,优先级的范围为1-10。下面是几个优先级的常量:

  • MIN_PRIORITY:最小优先级,值为1
  • NORM_PRIORITY:正常优先级,线程的默认优先级,值为5
  • MAX_PRIORITY:最大优先级,值为10

注意,优先级并不能代表先后顺序,只能导致执行的概率不同。优先级大的线程执行的概率会更大。

线程的同步

同步也称为并发。由于现在有多个线程,就可能导致多个线程访问同一份资源的问题。我们要确保这个资源安全,因此我们对其进行同步的处理。这样就可以保证它线程安全

也就是说,线程安全的资源就是指多个线程同时访问这个资源的时候,是同步的。这份资源是安全的。

概念介绍

原子性

原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作是原子操作。 比如:a++; 这个操作实际是a = a + 1;是可分割的 ,因此它不是原子操作。

非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。

**在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。 **

可见性

可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。 也就是一个线程修改的结果。另一个线程马上就能看到。

用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。

需要注意的是,**volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。 **

**在 Java 中 volatile、synchronized 和 final 实现可见性。 **

有序性

Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

Java内存模型

Java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。

JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

img

happens-before原则

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

指令重排序

介绍

Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

意义

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

synchronized关键字

我们通过synchronized标识符可以保证某一部分的线程安全,它是通过等待来实现的。我们把使用synchronized的行为叫做‘加锁’。当几个线程同时访问一份资源的时候,先到达的线程拿到锁,然后再轮到其他线程来。这样能保证线程的安全。

当我们加上synchronized后,就是为它加了一把锁。当多个线程同时来访问这份资源时,先到达的资源便可以得到这个锁,其他资源只能等待,等待结束后再执行。这样就保证了我们的线程安全。

由于线程安全是通过等待来进行,因此会造成效率的低下。为了减少这种效率损耗,我们应该尽量缩小加锁的范围,来提高效率。

同步块

我们都知道,{}及里面的语句表示一个同步块。在它前面加上synchronized(引用类型)标识符,就变成了一个同步块。

而synchronized锁 类.class 时,比较特殊。主要是用于给一些静态的对象加锁。(如线程安全的单例模式就会用到)

同步方法

在方法的前面加上synchronized,就称这个方法是同步方法。0

volatile关键字

线程对于普通的共享对象的操作发生在本地内存中,有时可能来不及同步到主内存中,就开始了下一个线程的处理。因此我们就可以用volatile来保证这个共享对象的可见性,强制将内存刷新到主内存中。

volatile具备两种特性,第一就是保证共享变量对所有线程的可见性。将一个共享变量声明为volatile后,会有以下效应:

    1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;

    2.这个写会操作会导致其他线程中的缓存无效。

死锁

过多的同步容易造成死锁。

对一个对象多线程同步访问,容易造成死锁。我们可以通过信号量来解决这个问题。

信号量

介绍

之前讲到线程锁,一个线程如果锁定了一资源,那么其它线程只能等待资源的释放。也就是一次只有一个线程执行,直到这个线程执行完毕或者unlock。而信号量(Semaphore)可以控制多个线程同时对某个资源的访问。

信号量(Semaphore)是用来保护一个或者多个共享资源的访问,Semaphore内部维护了一个计数器,其值为可以访问的共享资源的个数。一个线程要访问共享资源,先获得信号量,如果信号量的计数器值大于1,意味着有共享资源可以访问,则使其计数器值减去1,再访问共享资源。

就好比一个厕所管理员,站在门口。如果有厕所有空位,就开门允许与空厕数量等量的人进入厕所。多个人进入厕所后,相当于N个人来分配使用N个空位。为了避免多个人来同时竞争同一个厕所,在内部仍然使用锁来控制资源的同步访问。

使用

Semaphore使用时需要先构建一个参数来指定共享资源的数量,Semaphore构造完成后即可获取Semaphore。共享资源使用完毕后释放Semaphore。

比如下面的代码就是模拟控制一个商场厕所的并发使用:

评 论 区

  1. 还没有任何评论,你来说两句吧

发表评论

%d 博主赞过: