基础知识(一)
一、为什么需要多线程和多线程需解决的问题
我认为并发编程的产生实际是一种妥协,在硬件生产技术或者说成本的限制下导致了计算机运行的各个部件之间有了运行速度上的差异,为了最大化发挥计算机的计算能力并最小化所需付出的成本而通过一系列复杂的、抽象的手段进行速度提升。
试想如果我们现行技术能够做到所有的存储介质传输速度等同于CPU计算速度,任何数据计算传输近乎实时,那也就不需要并发了。同时我们要明白一个问题,多线程执行并不会提高单个任务的响应时间,并且还会降低单个任务的响应时间(原因在于线程切换所产生的消耗),多线程技术真正降低了任务的平均等待时间。
多线程需要解决的问题
**活跃性问题 **
在多线程执行中如果会因为多种原因产生线程无法向前推进,如死锁、饥饿、活锁等
死锁通常是由多个线程分配资源顺序不当导致的
饥饿是由于某个线程优先级低而导致永久不会得到执行
活锁是当某个线程需要进行的操作一直失败,虽然没有产生阻塞,但是线程在失败处只能反复执行而无法前进
正确性问题
正确性就是要求一段函数或代码在任何时候进行多次运行,在输入条件不变的情况下,输出结果是一致的,也即会执行环境不会影响执行结果。简单来说,就是一段程序在单线程和多线程的环境下执行的结果是一致的。
安全性问题
安全性是一个组合的概念,一般来讲当你的程序在多线程条件下解决了活跃性问题和正确性问题后,也即该段程序是线程安全的。
二、什么是线程安全
线程安全性是一个不容易被准确描述的概念,对于面向对象语言来说,线程安全即在多线程条件下共享的对象是安全的,可以分为三个部分来具体描述对象的线程安全性。 满足以下三个条件:
先验状态
域不变性 --------> 组合产生对象的安全性
后验状态
先验状态指对象在执行前是正确的完成了正确初始化和一定的先期条件,那什么时候会在初始化过程中出现错误呢?
//错误示例
public class NoSafe {
private int num;
NoSafe(int a) {
num = a;
Thread thread = new Thread(() -> num+=a);
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
NoSafe noSafe = new NoSafe(11);
System.out.println(noSafe.num); //输出结果为22,期望值为11
}
}
这段代码简单的体现了一个先验状态下多线程执行的问题所在,当然大部分情况下我们不会如此写代码。通过这个例子只是展示下如何在初始化状态下出现问题。所以为了保证多线程下初始化对象的正确性,尽量简化在构造器中的操作,同时不要在构造器中创建线程。
域不变性条件通常是在执行状态下域是不会被改变的或在改变时所有线程都会知晓改变的状态,域不变性条件是在保证对象的线程安全性中最重要的概念。保证域不变性条件的主要手段有线程安全委托( 原子性操作)、线程封闭(无状态)和线程同步(锁机制)等手段。
/*线程安全委托,对象Thing是否安全取决于status属性是否安全,
而在status属性为不可变的情况下,该对象是线程安全的*/
class Thing {
private final int status;
public Thing(int status) {
this.status = status;
}
public String getAttr() {
String a;
switch (status) {
case 1:
a = "1";
break;
case 2:
a = "2";
break;
default:
a = "other";
}
return a;
}
//线程封闭
class ThreadClosed{
private ThreadLocal<String> local = new ThreadLocal<>();
public void doSomeThing(){
local.set("thread");
}
public void doSomeThing2(){
local.get();
}
}
//线程同步,锁机制
class Synchronize {
private int num;
public synchronized void setNum(int n) {
num += n;
}
后验状态是在对象执行完成后的状态是可控的,在一定的输入下,有固定的输出。
三、保证线程安全的手段
(一) 对象的安全发布
对象的发布就是将对象交给另一个对象使用,这个交接的方式是决定对象是否是安全的一个基础,最典型的问题是发布的对象存在public修饰的成员变量,这样对象被发布出去后状态将不可控。所以安全的发布对象就是将对象的状态封闭,提供getter/setter公共访问接口进行访问。
(二)线程封闭的方式
- ad-hoc 代码约束保证封闭性(不推荐,十分脆弱)
- 栈封闭,使对象中不存在成员变量,只存在局部变量,这样变量被封闭在栈上也就是线程安全的
- 使用ThreadLocal,java提供的ThreadLoca是线程封闭的,每个线程独立持有自己的部分
使用线封闭的方式实现线程安全是最简单也是使用最广的方式,例如servlet技术并不是线程安全的,但在约定规范时保证servlet将变量通过栈封闭或使用ThreadLocal的方式存储而实现了事实上的线程安全。
(三)发布不可变的对象
线程安全问题实际上就是在共享消息(对象)时,因为线程执行的不可预期而导致一个线程的修改对另一个线程不可见。那么当发布的对象再发布的那一刻即为不可变对象,也就不会存在线程安全问题。
发布一个不可变对象有三个条件
- 保证对象在创建后不可修改
- 所有实例域都为final修饰
- 构造期间this未逸出
这三个条件中对于实例域都为final修饰并不是必须的,当实例域都为final修饰时这个对象就是不可变的,而如果将所有域声明为private,并且不提供任何修改接口,那么这个对象将成为事实不可变对象。不论是不可变对象还是事实不可变对象都是时线程安全的。
(四)同步技术
同步技术一般来讲有两种实现方式:阻塞同步和CAS。在java中提供了synchronized关键字,该同步机制又称为监视器锁,这种加锁在1.5之前是阻塞同步的重量级锁,在1.5后做了大量优化提供了锁粗化、偏向锁、轻量级锁等机制。
(五)线程安全容器和工具
在JUC (java.util.concurrent)包中也提供了大量的线程安全类,如concurrentHashMap、CopyOnWriteArrayList等容器,ReentrantLock等基于CAS操作的锁。
一些补充
一个类是否线程安全的是由该类的所有状态即成员变量的值所决定的,大部分时我们所使用的类都不是只有一个成员变量这么简单,作为有多个成员变量所组成的对象,它的安全必须是保证复合状态的一致性。除了状态的复合问题外,还有复合操作的问题,如一个操作必须以另一个操作的结果为前提,那么为保证线程安全就必须使这两个独立操作组合为一个原子性操作,通过是通过同步方式实现的。
可变状态是至关重要的
不可变状态一定是线程安全的
用锁来保护每个变量
复合操作要使用同一把锁
封装有助于降低复杂性
在多线程访问一个变量时要保证访问的同步
在设计中考虑线程安全问题,或在文档中指出它不是线程安全的
将同步策略文档化
参考:java并发编程实战 -java concurrency in practice