如果世界只有一个人,那么这个人可以操作任何的东西,不会有另外的人跟他抢夺东西;而真实的世界是存在很多人,他们可以同时做同样的事情,就有可能互相干扰,多线程程序也是如此。所以无论是现实世界还是虚拟的程序中,都要防止这种冲突。
21.3.1 不正确的访问资源
多线程造成的问题在于同时可以操作一个对象,导致对象被一个线程变更后,另外一个线程又去变更,此时第一个线程还按照之前的值进行计算,就会出现问题。
Java中的自增运算法++不是原子性的,线程不安全的。
比如:
int value = 0;
public int next() {
++value;
++value;
}
这种情况下,多线程执行的结果不一定是2的倍数。
21.3.2解决共享资源竞争
如果现实世界是一种程序的仿真,出现线程不安全的场景会让你莫名其妙。比如你刚坐上汽车,车就瞬间开出10公里外,是不是很不可思议。还好真实的世界没有上帝之手在操控。
现实世界中,当这个汽车是你的时候,其他人在合法的情况下是不能占用的。这意味着在给定时刻只允许一个任务访问资源。而你拥有汽车的凭证可以认为是一种互斥量(mutext).举一个公共资源的例子,比如卫生间有个小门,当有一个人进去的时候,会把门锁上,其他人看到之后就会在外面等,直到第一个人出来,后面的人才能进去;这个门锁可以认为是一种互斥量。程序中,当锁被打开后,等待的线程会随机的获取到锁;你也可以通过setPriority()给线程调度器建议,实际效果要看具体平台以及JVM的实现。
Java提供关键字synchronized,为防止资源冲突提供了内置支持。
共享资源一般是以对象形式存在的内存片段,也可以是文件、IO端口。要控制共享资源的访问,把访问共享资源的方法通过synchronized标记。
所有的对象都有单一的锁(监视器),当访问对象的方法被synchronized标记后,调用方法会在对象上加锁,调用完毕后释放锁。使用并发时,需要将域设置成private,避免非synchronized方法访问到对象,否则产生并发问题。
一个任务可以多次获取对象锁。比如访问了同步方法1,又访问同步方法2,此时均包含同一个对象,那么对象锁的计数器会变成2,执行完一个方法,锁计数器减1.
什么时候使用同步?当你的对象可能同时被多个任务使用时,读写都需要使用相同的锁同步。
使用Lock对象,可以显式的使用互斥机制。
创建锁对象:Lock lock = new ReentrantLock();
代码加锁:lock.lock();
代码关闭锁:lock.unlock();
一般lock()和unlock()之间的代码使用异常捕获机制try,代码执行完后,在finally代码快中执行unlock(),确保unlock一定会被执行。
synchronized与Lock的区别:
使用synchronized,代码量更少,编码出错的概率低,优先使用。
Lock主要用于一些特殊场景,比如获取锁设置超时等待时间。lock.tryLock()尝试获取锁,lock.tryLock(Integer, TimeType)尝试获取锁一段时间,超时后放弃。
21.3.3原子性与易变性
一个不正确的认识“原子操作不需要进行同步控制”。原子操作,是指不能被线程调度机制中断的操作,一旦操作开始,一定可以在可能发生的上下文切换之前执行完毕,不可再分解的操作。
long和double在64位的JVM上的读取和写入会分离成两个32位的操作,所以它的操作可能被上下文切换中断,产生不正确的结果。可以使用volatile关键字,通过线程机制保证其不可中断。
在多处理器系统,可视性问题很多。不同任务对应不同的视图(视图可以理解成线程自己的本地变量),任务之间视图是不可见的。同步机制强制在处理器的系统中,一个任务做出的修改必须在应用中是可视的。操作系统提供这样的机制,volitile则可以利用这个机制。
volatile确保了应用的可视性。一个被声明为volatile的域,只要产生写操作,会立即被写入到主存中;其他任务读取的时候都会从主存中读取。非valatile的域则不会从主存中读取属性值。
如果一个域的改变完全被synchronized修饰的方法或语句块保护,那么不必须设置volatile。
21.3.4原子类
Java提供原子性变量类,AtomicInteger、AtomicLong、AtomicReference等。他们提供以下形式的原子性条件更新操作:
boolean compareAndSet(expectedValue, updateValue);
常规的编程中不需要使用原子类,多线程编程才会用到。用锁来保持同步要比原子类更安全一些。
21.3.5临界区
临界区,也叫同步控制块,将方法内部的部分代码进行同步,而不是防止访问整个方法,这些部分代码被称为临界区。代码示例:
synchronized (syncObject) {
//这些代码可以同时被一个线程访问
}
进入此代码前,必须先获取syncObject对象的锁。如果其他线程获取了该锁,就得等锁释放才能进入执行这部分临界区的代码。
优势:通过使用同步控制块,而不是对整个方法进行同步控制,可以使多个任务访问对象的时间性能得到显著提高。
我们知道,同步本身会控制线程对于资源的访问。如果在一个执行方法中,由于内部逻辑执行较多,整体执行时间较长,此刻对整个方法进行同步控制,所有的多线程访问到了这里就会编程顺序执行,性能之差逐步变为程序的瓶颈。应该分析的是,在这个大而性能差的方法中,哪些逻辑是不需要进行同步的,我们只需对有共享资源访问的模块进行同步控制即可,除了将方法拆分细化,临界区给我们提供了一种可能,即在一个方法内,对同步进行更细粒度的控制。
21.3.6在其他对象上同步
synchronized块必须给定一个在其上进行同步的对象,synchronized(this),临界区的效果仅仅局限在访问当前对象的多线程任务上。
这涉及到同步锁生效的范围,this只是当前对象的锁范围;
如果多个流程中需要同样的锁进行同步,那么则需要指定一个其他对象;
如果要在当前jvm程序中进行全局同步,则需要一个全局的对象,用来提供对象锁,可以是一个静态对象;
如果要在多个jvm程序中进行全局同步,那么就需要一个超脱于jvm的一个对象标识,我们一般通过缓存中间件来处理,就是我们常说的分布式锁机制。
对象锁是多线程更底层的实现机制,会在其他文章中介绍。
21.3.7线程本地存储
使用线程同步是为了防止一个对象被多个线程同时访问,造成读取的信息有差异,进而导致程序不符合预期。而在某些场景下,则需要线程保持各自的特征,方便进行标识。
创建和管理线程本地存储可以由java.lang.ThreadLocal类来实现。ThreadLocal是泛型包装类,可以放其他类型的对象。
ThreadLocal对象通常当做静态域存储。在创建ThreadLocal时,只能通过set()、get()访问该对象的内容。get()方法将返回与其线程相关联的对象的副本,而set()会将参数插入到为其线程存储的对象中,并返回存储中原有的对象。get()方法不是同步的,因为ThreadLocal保证不会出现竞争条件。
点击去京东购买《Java编程思想(第四版)》