要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享(Shared)和可变的(Mutable)状态的访问。

  “共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。我们将像讨论代码那样来讨论线程安全性,但更侧重于如何防止数据在数据上发生不可控的并发访问。

  当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java 中的主要同步机制是关键字 synchronized ,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量,显式锁(Explicit Lock)以及原子变量。

如果当多个线程访问同一个可变的状态变量时,没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
  • 不在线程之间共享该状态变量
  • 将专题图变量修改为不可变的变量
  • 在访问状态变量时使用同步

  如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易的多。

当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。

2.1  什么是线程安全性  

  给线程安全性给出一个确切的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称为这个类时线程安全的。 
在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

  示例:一个无状态的 Servlet

public class StatelessFactorizer implements Servlet {
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = factor(i);
encodeIntoResponse(servletResponse, factors)
}
}

  与大多数Servlet 相同,StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。

无状态对象一定是线程安全的。

  

2.2  原子性

  假设我们希望增加一个“命中计数器”来统计所处理的请求数量。例如下代码

public class StatelessFactorizer implements Servlet {        【皱眉脸--不要这么做】
private long count = ;
public long getCount() {
return count;
}
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(servletResponse, factors)
}
}

  不幸的是 ++count 不是原子操作,这是一个“读取-修改-写入”的操作序列。(上一节讲的竞态条件)。

  2.2.1  竞态条件

    当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。

    最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。

  2.2.2  实例:延迟初始化中的竞态条件

public class LazyInitRace {            【皱眉脸--不要这样做】
private UnsafeSequence instance = null;
public UnsafeSequence getInstance() {
if (instance == null) {
instance = new UnsafeSequence();
}
return instance;
}
}

    存在另一种竞态条件,在“读取-修改-写入”这种操作。

  2.2.3  复合操作

    LazyInitRace 需要以原子方式执行(不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

假定有两个操作A 和B ,如果从执行 A 的线程来看,当另一个线程执行 B 时,要么将 B 全部执行完,要么完全不执行 B,那么 A 和 B 对彼此来说就是原子的。原子操作是指:对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作时一个以原子方式执行的操作。

    所以,我们将介绍加锁机制,这是Java 中用于确保原子性的内置机制。使用 AtomicLong 类型的变量来统计已处理请求的数量。

public class StatelessFactorizer implements Servlet {
private AtomicLong count = new AtomicLong();
public long getCount() {
return count.get();
}
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(servletResponse, factors);
}
}

    在 java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。

2.3  加锁机制

  当在Servlet 中添加一个状态变量时,可以通过线程安全的对象来管理 Servlet 的状态以维护 Servlet 的线程安全性。但如果想在 Servlet 中添加更多的状态,那么是否只需要添加更多的线程安全状态变量就足够了?

  假设我们希望提升 Servlet 的性能:将最近的计算结果缓存起来。看代码:

public class UnsafeCachingFactorizer implements Servlet {                    【皱眉脸--不要这样做】
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(servletResponse, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);      //同步1
lastFactors.set(factors);  //同步2
encodeIntoResponse(servletResponse, factors);
}
}
}

  尽管这里面的原子引用本身都是线程安全的,但是 还是存在着竞态条件。同步1 和 同步2 不是原子操作,可能存在问题。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量

  2.3.1  内置锁

    Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。静态的synchronized 方法以 Class 对象作为锁。

    Java 的内置锁相当于一种互斥锁,这意味着最多只有一个线程能持有这种锁。由这个锁保护的同步代码会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。

    在上一个程序清单里,如果使用关键字 synchronized 来修饰 service 方法,因此在同一个时刻只有一个线程可以执行 service 方法。然而,这种方法却过于极端,因为多个客户端无法同时使用Servlet ,服务响应性非常低,无法令人接受。

  2.3.2  重入

    当某个线程请求一个由其他线程持有的锁时,发出请求的线程会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个由自己持有的锁,那么这个请求就会成功。

    “重入” 意味着获取锁的操作的粒度是“线程”,而不是“调用”。

    下面代码清单:如果内置锁不是可重入的,那么这段代码将发生死锁。

public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}

2.4  用锁来保护状态

  由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议来实现对共享状态的独占访问。

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员指导是哪一个锁。

  一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。 

  当类的不可变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护

  如果同步可以避免竞态条件,那么为什么不在每个方法声明时都使用同步呢?事实上,如果不加区别的滥用 synchronized ,可能导致程序中出现过多的同步。

  此外,将每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance)。

2.5  活跃性与性能

  之前有介绍对整个 service 方法进行同步,虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。我们将这种应用程序称为不良并发(Poor Concurrent)应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。

  缓存最近执行因数分解的数值 及其计算结果的 Servlet:

public class UnsafeCachingFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
private long hits;
private long cacheHits;
public synchronized long getHits() { return hits;}
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = null;
synchronized (this) {
++hits;
if (i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if (factors == null) {
factors = factor(i);
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(servletResponse, factors);
}
} 

  在上面的改造后的代码 实现了在简单性(对整个方法进行同步) 与并发性(对尽可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上都需要一定的开销,因此如果将同步代码块分解的过细,那么通常并不好,尽管这样不会破坏原子性。

通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(着可能会破坏安全性)

  无论是执行计算密度的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题  

当执行时间较长的计算或者可能无法快速完成的操作时(例如:网络 I/O 或控制台 I/O),一定不要持有锁。

  

【Java并发.2】线程安全性的更多相关文章

  1. Java 并发基础——线程安全性

    当线程安全:多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协调,这个类都能表现出正确的行为,那么久称这个类是线程安全的. 在线程 ...

  2. Java 并发 中断线程

    Java 并发 中断线程 @author ixenos 对Runnable.run()方法的三种处置情况 1.在Runnable.run()方法的中间中断它 2.等待该方法到达对cancel标志的测试 ...

  3. Java 并发编程 | 线程池详解

    原文: https://chenmingyu.top/concurrent-threadpool/ 线程池 线程池用来处理异步任务或者并发执行的任务 优点: 重复利用已创建的线程,减少创建和销毁线程造 ...

  4. java并发编程 线程基础

    java并发编程 线程基础 1. java中的多线程 java是天生多线程的,可以通过启动一个main方法,查看main方法启动的同时有多少线程同时启动 public class OnlyMain { ...

  5. 【Java并发基础】安全性、活跃性与性能问题

    前言 Java的多线程是一把双刃剑,使用好它可以使我们的程序更高效,但是出现并发问题时,我们的程序将会变得非常糟糕.并发编程中需要注意三方面的问题,分别是安全性.活跃性和性能问题. 安全性问题 我们经 ...

  6. Java并发1——线程创建、启动、生命周期与线程控制

    内容提要: 线程与进程 为什么要使用多线程/进程?线程与进程的区别?线程对比进程的优势?Java中有多进程吗? 线程的创建与启动 线程的创建有哪几种方式?它们之间有什么区别? 线程的生命周期与线程控制 ...

  7. java并发:线程同步机制之Volatile关键字&原子操作Atomic

    volatile关键字 volatile是一个特殊的修饰符,只有成员变量才能使用它,与Synchronized及ReentrantLock等提供的互斥相比,Synchronized保证了Synchro ...

  8. java并发:线程池、饱和策略、定制、扩展

    一.序言 当我们需要使用线程的时候,我们可以新建一个线程,然后显式调用线程的start()方法,这样实现起来非常简便,但在某些场景下存在缺陷:如果需要同时执行多个任务(即并发的线程数量很多),频繁地创 ...

  9. java并发:线程同步机制之Lock

    一.初识Lock Lock是一个接口,提供了无条件的.可轮询的.定时的.可中断的锁获取操作,所有加锁和解锁的方法都是显式的,其包路径是:java.util.concurrent.locks.Lock, ...

  10. Java并发编程:线程间通信wait、notify

    Java并发编程:线程间协作的两种方式:wait.notify.notifyAll和Condition 在前面我们将了很多关于同步的问题,然而在现实中,需要线程之间的协作.比如说最经典的生产者-消费者 ...

随机推荐

  1. Django Admin后台管理用户密码修改

    方法一 在Terminal中执行:python manage.py changepassword your_name(其中“your_name”为你要修改密码的用户名),根据提示内容修改即可. 方法二 ...

  2. katalon之web文件上传

    参考:https://docs.katalon.com/katalon-studio/docs/webui-upload-file.html#example- 适用范围:tag=input, type ...

  3. RMAN restore fails with ORA-01180: can not create datafile 1

      最近在验证.测试备份有效性时,遇到了"ORA-01180: can not create datafile 1"这个错误,顺便结合metalink的官方文档"RMAN ...

  4. MyBatis笔记----报错Exception in thread "main" org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.ij34.model.UserMapper.selectUser

    信息: Refreshing org.springframework.context.support.ClassPathXmlApplicationContext@41cf53f9: startup ...

  5. [20190213]学习bbed-恢复删除的数据.txt

    [20190213]学习bbed-恢复删除的数据.txt --//以前也做过类似测试,当时在用bbed做verify时错误都不处理,当时的想法就是能读出就ok了.--//而且当时也做成功,纯粹是依葫芦 ...

  6. CSS实现三列布局

    三列布局指的是两边两列定宽,中间的宽度自适应. 常用三种方法: 定位 浮动 弹性盒布局 定位方式 最直观和容易理解的一种方法,左右两栏选择绝对定位,固定于页面的两侧,中间的主体选择用margin确定位 ...

  7. Django中间件的使用

    Django中间件的使用 中间件(middleware) 中间件应用于request与服务端之间和服务端与response之间,客户端发起请求到服务端接收可以通过中间件,服务端返回响应与客户端接收响应 ...

  8. C++多线程同步技巧(四)--- 信号量

    简介 信号量是维护0到指定最大值之间的同步对象.信号量状态在其计数大于0时是有信号的,而其计数是0时是无信号的.信号量对象在控制上可以支持有限数量共享资源的访问,可以用于线程同步,预防死锁等领域. 信 ...

  9. sqli-labs第一节 get-字符型注入

    https://blog.csdn.net/sherlock17/article/details/64454449   1.SQL注入漏洞的几种判断方法 ①http://www.heetian.com ...

  10. 卸载安装node npm (Mac linux )

    1. 卸载node npm (1) 先卸载 npm: sudo npm uninstall npm -g (2) 然后卸载 Node.js. (2.1) 如果是 Ubuntu 系统并使用 apt-ge ...