爱吃鱼饵

 

Java并发基础

多进程和多线程有哪些区别呢?

  • 资源:每个进程有自己的一套变量,而线程则可以共享数据。
  • 通信:共享变量使得线程之间的通信比进程之间的通信更加有效、更容易;
  • 创建:创建、撤销一个线程比启动新进程的开销小得多。

什么是线程

使用线程给其他任务提供机会

⚠️ 不要调用Thread类或Runnable对象的run方法。直接调用run方法,指挥执行同一个线程中的任务,而不会启动新线程。应该调用 Thread.start 方法,这个方法将创建一个执行run方法的新线程。

中断线程

没有可以强制线程终止的方法。然而,interrupt方法可以用来请求终止线程

对一个线程调用 interrupt 方法时,线程的 中断状态 将被置位。这是每一个线程都具有的boolean标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。

while(!Thread.currentThread().isInterrupted && more work){  // 实例方法
	do more work
}

如果线程被阻塞,就无法检测中断状态。这是产生InterruptedException异常的地方。当在一个被阻塞的线程(调用sleep或wait)上调用 interrupt方法时,阻塞调用将会被 Interrupted Exception异常中断。

中断一个线程只是引起线程的注意,被打断的线程可以决定如何响应中断。

如果使用了循环,并且迭代工作后使用可以被打断的方法(sleep等),那么 isInterrupted 检测既没有必要,也没有用处,因为当线程标记了中断标志,sleep 方法不会再执行,同时将中断标记清除

📓 注释:有两个非常类似的方法, interrupted和 isinterrupted. interrupted方法是一个静态方法,它检测当前的线程是否被中断。而且,调用interrupted方法会清除该线程的中断状态。另一方面, isinterrupted方法是一个实例方法,可用来检验是否有线程被中断。调用这个方法不会改变中断状态!

线程状态

要确定一个线程的当前状态,getState方法。

新创建线程

使用new操作符新创建一个线程时, 如 new Thread(r), 线程处于创建状态。

可运行线程

一旦调用 start方法,线程处于 runnable状态。一个可运行的线程可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。

被阻塞和被等待线程

当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。细节取决于它是怎样达到非活动状态的

被终止的线程

运行exits方法。

线程属性

线程优先级

默认情况下,线程将继承父线程的优先级。可以用setPriority方法提高或降低任何一个线程的优先级。

不要将程序构建为功能的正确性依赖于优先级。

🚧 警告:如果确实要使用优先级,应该避兔初学者常犯的一个错误。如果有几个高优先级的线程没有进入非活动状态,低优先级的线程可能永远也不能执行。每当调度器决定运行一个新线程时,首先会在具有高优先级的线程中进行选择,尽管这样会使低优先级的线程完全饿死。

守护线程

守护线程的唯一用途是为其他线程提供服务。计时线程就是一个例子。

未捕获异常处理器

线程的run方法不能抛出任何受查异常,但是,非受查异常会导致线程终止

但是,不需要任何 catch子句来处理可以被传播的异常。相反,就在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。

自定义用于未捕获异常的处理器类可以安装到线程中,然后使用日志API发送未捕获异常的报告到日志文件。

同步

竞争条件的一个例子

总体有一个银行,每个账号都有1000块钱,然后将创建一个线程,调用一个函数,随机在两个账号之间转账,多个线程调用银行的方法同时将钱从一个账户将钱转给另一个账户,最后银行钱数的总和会变化。

竞争条件

两个线程同时更新同一个账户时,问题就出现了,因为更新操作不是原子操作,一个更新操作可以处理为以下几个步骤:

  1. 将原来的数加载到寄存器
  2. 增加值
  3. 写回原来的内存。

在t1 和 t2 同时对同一个账户进行更新时,当 t1 执行到第二步,t2已经更新完并写入内存中去了,这时t1执行了第三步,那么t2做出的修改被擦除,导致结果出错。

线程运行权问题去考虑,线程运行时间片过长,导致t1停止在第二步。

锁对象

两种机制防止代码块受到并发访问的干扰。

  1. synchronized
  2. Reentrantlock类

synchronized 提供了一个锁以及相关的"条件",对大多数需要显式锁的情况,非常便利。

ReentrantLock 保护代码块的基本结构如下:

mylock.lock();
try{
	临界区;
}
finally{
	mylock.unlock;  // 保证出错时释放锁。
}

🚸 警告:把解锁操作括在 finally子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞。

📓 注释:如果使用锁,就不能使用带资源的try语句:

  1. 解锁方法名不是close.
  2. 首部希望声明一个新变量,但如果使用锁,多个线程是共享那个变量的。

假定一个线程调用 transfer,在执行结束前被剥夺了运行权。假定第二个线程也调用 transfer,由于第二个线程不能获得锁,将在调用1ock方法时被阻塞。它必须等待第一个线程完成 transfer方法的执行之后オ能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行(见下图)。

非同步线程和同步线程的比较

锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个 持有计数(hold count) 来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。(「锁粗化」)

通常,可能想要保护需若干个操作来更新或检查共享对象的代码块。要确保这些操作完成后,另一个线程才能使用相同对象。

⚠️ 警告:要留心临界区中的代码,不要因为异常的抛出而跳出临界区。如果在临界区代码结束之前抛出了异常,finally子句将释放锁,但会使对象可能处于一种受损状态.

公平锁的性能一般比常规锁慢很多,只有了解了确定要使用才使用。公平锁不见得是完全公平的,线程调度器可能选择忽略一个线程。

条件对象

通常,线程进入临界区,却发现在某一条件满足后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。

当需要转账,而账户金额不够时,线程需要等待另一个线程向账户注入的资金。

一个锁对象可以有一个或多个相关的条件对象。你可以用 newCondition方法获得个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“余额充足”条件。

等待获得锁的线程和调用await方法的线程存在本质上的不同。一且一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的 signAll方法时为止。

在此之后,线程应该再次测试该条件。由于无法确保该条件被满足---signalAll方法仅仅是通知正在等待的线程:虽然有可能已经满足条件,值得再次去检测该条件。

📓 通常,对await的调用应该在如下形式的循环体中。

while(!(ok to proceed))
	condition.await();

最终需要某个其他线程调用了signalAll方法。当一个线程调用await时,它没有办法重新激活自身。

应该何时调用 signalAll呢?经验上讲,在对象的状态有利于等待线程的方向改变时调用signalAll方法。

🙅‍♂️ 调用signalAll不会立即激活一个等待线程。它仅仅接触等待线程的阻塞,以便这些线程在当前线程退出同步方法之后,通过竞争实现对对象的访问。

「signal方法」:随机解除等待集中某个线程的阻塞状态,这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,它将再次被阻塞,如果没有其他线程再次调用signal,那么系统就死锁了。

⚠️ 当一个线程拥有某个条件的锁时,它仅仅可以在该条件上调用await、signalAll或signal方法。

synchronized关键字

Lock和 Condition接口为程序设计人员提供了高度的锁定控制。然而,大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到]ava语言内部的机制。

在java1.0版开始,每个对象都有一个内部锁

如果一个方法用 synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。

内部对象锁只有一个相关条件。 wait方法添加一个线程到等待集中notifyAll/ notify方法解除等待线程的阻塞状态。换句话说,调用wait或notifyAll等价于 锁对象调用await和signAll方法。

📓注释:wait、notifyAll以及notify方法是Object类的final方法。Condition方法必须被命名为 await、 signalAll和 signal以便它们不会与那些方法发生冲突。

使用 synchronized关键字来编写代码要简洁得多。当然,要理解这一代码,你必须了解每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入 synchronized方法的线程,由条件来管理那些调用wait的线程。

🐯 Synchronized方法是相对简单的。但是,初学者常常对条件感到困惑。

❓ 内部锁和条件存在一些局限,在代码中使用哪一种?Lock和Condition对象还是同步方法?

同步阻塞

在对象中创建一个Object对象作为锁,用来实现额外的原子操作,实际上被称为”客户端锁定“。

监视器概念

锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。

Java设计者以不是很精确的方式采用了监视器概念,]ava中的每一个对象有一个内部的锁和内部的条件。如果一个方法用 synchronized 关键字声明,那么,它表现的就像是一个监视器方法。通过调用wait/ notifyAll/ notify来访问条件变量

Volatile域

有时,仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。

如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当地重新排序指令。

📓 Brian Goetz 给出了下述”同步格言“: 如果向一个变量写入值,而这个变量接下来可能会被另外一个线程读取,或者,从一个变量读值,而这个变量可能是之前被另一个线程写入的,此时必须使用同步。

volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

⚠️ Volatile不能提供原子性,当变量的操作涉及到执行引擎的多个指令时,那么在执行操作的过程中不能保证正确性。

final变量

除非使用锁或者volatile修饰符,否则无法从多个线程安全地读取一个域。

将这个域声明为final时,可以安全地访问一个共享域。

其他线程会在构造函数初始化之后才看到这个声明为final的这个变量。

原子性
假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些变量声明为volatile。

java.util.concurrent.atomic包中有很多类实现了很高效的机器级指令(不使用锁)来保证操作的原子性。

💱 安全生成一个数值序列。使用AtomicInteger类提供了方法 incrementAndGetdecrementAndGet, 分别使用原子方式自增或自减。

  • 也就是说,获取值,加一,设置新值这个操作不会被中断

如果需要实现复杂的更新操作,compareAndSet可以支持。

🦅 假设需要跟踪不同线程观察的最大值。以下代码会出现问题,无法保证原子性。

应当在一个循环中计算新值和使用compareAndSet:

do {
	oldValue = largest.get();
	newValue = Math.max(observerd, oldValue);
}while(!largest.compareAndSet(oldValue, newValue));

当另外一个线程在更新时,会阻止这个线程更新,这样一来compareAndSet会返回false, 而不会设置新值。重试操作,直到成功。

听起来很麻烦,但是compareAndSet会映射到一个处理器操作,比使用锁速度更快。

👿 在java8 中,在原子更新方法中传入一个lambda表达式更新变量,为你完成更新操作。

largest.updateAndGet(x -> Math.max(x, observed));

或者

largest.accumulateAndGet(observerd, Math::max);

accumulateAndGet方法利用一个二元操作符来合并原子值和所提供的参数。

📓 类AtomicInteger、AtomicIntegerArray等也提供了这些方法。

如果有 大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试

LongAdder和LongAccumulator可以解决这个问题。

死锁

锁和条件不能解决多线程中的所有问题。

例子1:当两个线程操作两个账户相互进行汇款,但是每个账户内部的金额不够,都会进入等待状态,将会阻塞,无法向下执行。

例子2:在对银行的所有账户随机进行汇款时,将signalAll改为signal时可能会导致死锁,signal指的是唤醒条件对象中的线程等待集中的一个线程,如果该线程无法执行那么,整个线程集中的线程可能都无法执行下去。

遗憾的是,Java编程语言中没有任何东西可以避免或打破这种死锁现象。必须仔细设计程序,以确保不会出现死锁。

线程局部变量

前面几节中,我们讨论了在线程间共享变量的风险。有时可能要避免共享变量,使用 ThreadLocal辅助类为各个线程提供各自的实例。例如, SimpleDateFormat类不是线程安全的。

方案:

  1. 同步使用同一个对象;
  2. 每次使用构造一个局部对象;
  3. 为每个线程构造一个实例, ThreadLocal技术。

为每一个线程创建一个随机数生成器,ThreadLocalRandom。

锁测试与超时

线程在调用lock方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。 tryLock方法试图申请一个锁,在成功获得锁后返回true,否则,立即返回false,而且线程可以立即离开去做其他事情。

调用tryLock时,可以使用超时参数:

myLock.tryLock(100, TimeUnit.MILLSECONDS);

TimeUnit是一个枚举类型,可以取的值包括 SECONDS、 MILLISECONDSMICROSECONDS和 NANOSECONDS。

lock方法不能被中断。如果被打断,中断线程在获得锁之前一直处于阻塞状态,如果出现死锁,那么lock方法就无法终止。

但是, 如果调用带有超时参数的tryLock,那么如果线程在等待期间被中断,将会抛出 InterruptedException 异常,这允许程序打破死锁。

「在等待一个条件时,也可以提供一个超时。」

读写锁

java.util. concurrent.locks包定义了两个锁类,我们已经讨论的Reentrantlock类和 Reentrantreadwritelock类。如果很多线程从一个数据结构读取数据而很少修改其中数据的话,后者是十分有用的。在这种情況下,允许对读者线程共享访问是合适的。当然,写者线程依然必须是互斥访问的。

使用读写锁的必要步骤:

  1. 构建Reentrantreadwritelock对象;
  2. 抽取读写锁;
  3. 对所有的获取方法加读锁;
  4. 对所有的修改方法加写锁;

阻塞队列

对于实际编程来说,应该尽可能远离底层结构.

对于许多线程问题,可以通过使用一个或多个队列以优雅且安全的方式将其形式化.

生产者线程向队列插入元素,消费者线程则取出它们.使用队列可以安全地从一个线程向另一个线程传递数据.

阻塞队列方法如下:

阻塞队列方法分为以下3类,这取決于当队列满或空时它们的响应方式。如果将队列当作「线程管理工具」来使用,将要用到put和take方法。当试图向满的队列中添加或从空的队列中移出元素时,add、 remove,和 element操作抛出异常。当然,在一个多线程程序中,队列会在任何时候空或满,因此,一定要使用 offer、poll和peek方法作为替代。这些方法如果不能完成任务,只是给出一个错误提示而不会抛出异常.

📓 注释: poll和peek方法返回空来指示失败.因此不能在队列中插入null值.

带有超时地offer方法和poll方法地变体.

JUC下阻塞队列地几个变种:

阻塞队列 特性
LinkedBlockingQueue 没有容量上界,可以指定最大容量. 双向链表实现.
ArrayBlockingQueue 构造时需要指定容量, 可选参数实现公平性.若公平,那么等待了最长时间的线程会优先得到处理。<循环数组实现>
PriorityBlockingQueue 带有优先级地队列,而不是先进先出队列.元素按照优先级顺序被移出,容量没有上限. 可以设置初始队列的容量.
DelayQueue 包含实现Delayed地接口地对象.元素只有在延迟用完地情况下才能从队列中移出,必须实现compareTo方法.DelayQueue使用该方法对元素进行排序.
TransferQueue接口 允许生产者线程等待,直到消费者准备就绪接收一个元素. LinkedTransferQueue类实现了这个接口.

🏷️ 举个例子, 在给定地目录下以及其子目录下的所有文件中找到关键词信息.

线程安全的集合

如果多线程要并发地修改一个数据结构,例如散列表,那么很容易破坏这个数据结构.

可以通过提供锁来保护共享数据结构,但是选择线程安全的实现作为替代可能更容易些.

高效的映射, 集合和队列

JUC包下提供了安全集合的高效实现: ConcurrentHashMap, ConcurrentSkipListMap, ConcurrentSkipListSet和ConcurrentLinkedQueue.

这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化.
size()方法不必在常量时间内操作, 确定这样的集合大小通常需要遍历.

📓 注释:有些应用使用庞大的并发散列映射,这些映射太过庞大,以至于无法用size方法得到大小,该方法只能返回int, 对于一个包含超过20亿条目的映射该如何处理? 引入mappingCount方法把大小作为long返回.

集合返回弱一致性的迭代器, 该迭代器不一定能反应出它们被构造出来后的修改.
📔 注释:与之形成对照的是,集合如果在迭代器构造之后发生改变,java.util包中的迭代器将抛出一个 Concurrentmodificationexception异常。

并发的散列映射表,可高效地支持大量的读者和一定数量的写者。默认情况下,假定可以有多达16个写者线程同时执行。可以有更多的写者线程,但是,如果同一时间多于16个,其他线程将暂时被阻塞。可以指定更大数目的构造器,然而,恐怕没有这种必要。
📓 散列映射将有相同散列码的所有条目放在同一个桶中, 有些应用使用的散列函数不当,以至于所有条目最后都放在很少的桶中,这会严重降低性能.

映射条目的原子更新

Concurrenthashmap原来的版本只有为数不多的方法可以实现原子更新,这使得编程多少有些麻烦。原子更新指的是, 事务是否满足原子性,如果不满足,则不是线程安全的, 比如以下代码:

传统的做法是使用replace方法, 自旋直到操作成功; 或者 使用ConcurrentHashMap<String, AtomicLong>或者SE8中,使用ConcurrentHashMap<String, LongAdder> 等, 更新代码如下:

map.putIfAbsent(word, new LongAddr());
map.get(word).increment();

第一个语句确保有一个 LongAdder可以完成原子自增。

📓 ConcurrentHashMap 中不允许有null值, 很多方法使用null值表示给定的键不存在.

javaSe 8中 merge, compute等方法也可以在其中使用.

对并发散列映射的批操作

Java SE8为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。无须冻结当前映射的快照。除非你恰好知道批操作运行时映射不会被修改,否则就要把结果看作是映射状态的一个近似。

并发集视图

并没有一个ConcurrentHashSet类, 可以使用ConcurrentHashMap的静态方法newKeySet()(这是一个泛型方法) 得到ConcurrentHashMap<K,Boolean>的包装器. 所有映射值都为Boolean.TRUE.

写数组的拷贝

CopyOnWriteArrayList和 CopyOnWriteArraySet是线程安全的集合, 其中所有的修改线程对底层数组进行复制. 如果在集合上进行迭代的线程数超过修改线程数,这样的安排很有用.

并行数组算法

在Java SE8中, Arrays类提供了大量并行化操作。静态 Arrays. parallelSort方法可以对一个基本类型值或对象的数组排序。

较早的线程安全集合

这些类不是线程安全的, 而集合库中提供了不同的机制. 任何集合类都可以通过使用同步包装器编程线程安全的.结果集合的方法使用锁加以保抑,提供了线程安全访问.

Callable与Future

Runnable封装一个异步运行的任务,可以把它想象成为一个没有参数和返回值的异步方法。 Callable!与 Runnable类似,但是有返回值。 Callable接口是一个参数化的类型,只有一个方法call.

Future接口具有下面的方法:

FutureTask包装器是一种非常便利的机制,可将Callable转换成 Future和Runnable,它同时实现二者的接口。

执行器

构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。如果程序中创建了大量的生命期很短的线程,应该使用线程池( ThreadPool)。

使用线程池可以减少并发线程的数目,创建大量线程会大大降低性能甚至使虚拟机崩溃.

执行器( Executor)类有许多静态工厂方法用来构建线程池.

线程池

上图中前三个方法返回实现了 ExecutorService接口的ThreadPoolExecutor类的对象.

提交对象执行的方法:

使用完线程池时, 调用shutdown.该方法启动该池的关闭序列, 不在接收新的任务.当所有任务完成,线程池中的线程死亡. shotdownNow, 取消尚未开始的所有任务,并视图中断正在运行的线程.

预定执行

Scheduledexecutorservice接口具有为预定执行( Scheduled Execution)或重复执行任务而设计的方法。

可以预定 Runnable或Callable在初始的延迟之后只运行一次。也可以预定一个Runnable)对象周期性地运行。

控制任务组

当将一组任务用集合封装时, 交给线程池去执行,那么对于返回的结果如果使用正常的线程池去操作,那么就会出现大的阻塞问题, 如果使用ExecutorCompletionService进行排列的话, 服务会管理一个阻塞队列,将所有已经提交的任务的执行结果包含进去,这样一来, 父线程就不会等待太久.

Fork-Join框架

有些应用使用了大量线程, 但其中大多数是空闲的. 比如Web服务器. 另外有些应用可能对每个处理器内核分别使用一个线程,来完成计算密集型任务.

可完成Future

利用可完成 future,可以指定你希望做什么,以及希望以什么顺序执行这些工作。

同步器

java.uti1. concurrent包包含了几个能帮助人们管理相互合作的线程集的类.

posted on 2021-08-02 14:54  爱吃鱼饵  阅读(114)  评论(0编辑  收藏  举报

导航