Java并发——线程安全

本文是《Java并发编程实战》第二章——线程安全 的读书笔记。
线程安全主要是从以下五个方面来谈谈,如下图:
这里写图片描述

前奏——
编写线程安全的代码,本质上就是管理对状态(state)的访问,而且通常都是共享的、可变的状态。

通俗地说,一个对象的状态就是它的数据,存储在状态变量(state variables)中,比如实例域或静态域.对象的状态还包括了其他附属对象的域。例如,HashMap的状态一部分存储到对象本身中,但同时也存储到很多Map.Entry对象中。因此,一个对象的状态包含了任何会对它外部可见行为产生影响的数据。

一个对象是否应该是线程安全的取决于它是否会被多个线程访问。线程安全的这个性质,取决于程序中如何使用对象,而不是对象完成了什么。保证对象的线程安全性需要使用同步来协调对其可变状态的访问;若是做不到这一点,就会导致脏数据和其他不可预期的后果。

无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。Java中首要的同步机制是synchronized关键字,它提供了独占锁。除此之外,术语“同步”还包括volatile变量,显示锁和原子变量的使用。

你会想到在一些“特殊”情况下上述规则并不适用,不过你应该抵制住这种想法的诱惑。程序如果忽略了必要的同步,可能看上去可以运行,而且能够通过测试,甚至能正常地运行数年,但它仍然是存在隐患的,任何时刻都有可能崩溃。

在没有正确同步的情况下,如果多个线程访问了同一个变量,你的程序就存在隐患。有3种方法修复它:
(1)不要跨线程共享变量;
(2)或者使状态变量为不可变的;
(3)在任何访问状态变量的时候使用同步.

在一个大型的项目中识别多个线程访问给定的变量——封装和数据隐藏,所以对程序封装得越好,越容易发现程序的不安全性。

这里也来区别一样“线程安全类”与“线程安全程序“这两个概念。
完全由线程安全类构成的程序未必是线程安全的,线程安全程序也可以包含非线程安全的类。(这部分等待深入研究之后举出具体的实例来说明–待续–)

正文
1、线程安全性的概念
自问两个问题,有助于我们开展下边的讨论:

我们如何辨别线程安全与非线程安全的类?

我们甚至又该如何理解“安全”呢?

一个类是线程安全的,是指在被多个线程访问时,类可以持续进行正确的行为。

当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要倾外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的
无状态下的Servlet,

public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        int i = extractFormRequest(servletRequest);
        int[] factors = factor(i);
        encodeIntoResponse(servletResponse,factors);
    }

StatelessFactorizer像大多数Servlet一样,是无状态的:它不包含域也没有引用其他类的域。一次特定计算的瞬时状态,会唯一地存在本地变量中,这些本地变量存储在线程的栈中,只有执行线程才能访问。一个访问StatelessFactorizer的线程,不会影响访问同一个servlet的其他线程的计算结果;因为两个线程不共享状态,它们如同在访问不同的实例。因为线程访问无状态对象的行为,不会影响其他线程访问该对象时的正确性,所以无状态对象是线程安全的。

(2)原子性
“++count”自增运算不是原子运算,多线程下会破坏线程安全。它包含”读-写-改“三个操作。

想象这样一个场景:
两个线程在缺乏同步的条件下,试图同时更新一个计数器时所发生的事情。假设计数器的初始值为9,在某些特殊的分时里,每个线程都将读它的值,并看到值是9,然后同时加1,最后都将counter设置为10。很明显,这不是我们期望发生的事情:一次递增操作凭空消失了,一次命中计数被永久地取消了。
这两个线程构成竞争条件
如下边程序

  private long count;
    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        int i = extractFormRequest(servletRequest);
        int[] factors = factor(i);
        ++count;
        encodeIntoResponse(servletResponse,factors);
    }

这样的程序是在多线程下是不可靠的。当计算的正确性依核于运行时中相关的时序或者多线程的交替时,会产生竞争条件。引出”检查再运行(check-then-act)“概念。
这个概念的例子——你观察到一些事情为真(文件X不存在),然后(then)基于你的观察去执行一些动作(创建文件X):但事实上,从观察到执行操作的这段时间内,观察结果可能已经无效了(有人在此期间创建了文件X),从而引发错误(非预期的异常,重写数据或者破坏文件)。

懒加载中的线程安全问题

补充:懒加载——是在最后使用的时候才初始化变量
如下代码

Product product=null;
    private long count;
    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        int i = extractFormRequest(servletRequest);
        int[] factors = factor(i);
        if(product==null){
            new Product("N/A");
        }
        ++count;
        encodeIntoResponse(servletResponse,factors);
    }

为了将引用在再次调用的时候直接调用就可以用,减少新建的耗掉性能。其实这样的懒加载也破坏了它在多线程中的正确性。
还存在另一种竟争条件“读一改一写”操作,比如递增计数器,它按照对象先前的状态来定义对象的状态转换。递增一个计数器,你必须要知道先前值,并且要确保你在更新的过程中,没有其他线程改变或使用计数器的值。

像大多数并发错误一样,竞争条件并不总是导致失败:还需要某些特殊的分时。但是竞争条件会引起严重的问题。如果LazyInitRace用于实例化一个应用级的注册器,让它在多次调用中返回不同的实例,会引起注册信息的丢失,或者多个活动得到不一致的己注册对象集合的视图。如果unsafesequence用于为持久性框架生成实体标识符,两个对象会由于相同的ID而消亡,因为它们破坏了标识符的完招性约束。

2、原子性——为了避免竞争关系,必须坚持原子性。

为了避免竟争条件,必须阻止其他线程访问我们正在修改的变量,让我们可以确保:当其他线程想要查看或修改一个状态时,必须在我们的线程开始之前或者完成之后,而不能在操作过程中。这也就导出Java内置的原子性机制——
这里补充一下:”检查再运行“与”读-改-写“都属于复合操作,这也是导致竞争关系的内因——它不是马上执行的。

其实并发包里面还有一个原子变量可以解决掉之前的两种竞争关系。
java.util.concurrent.atomic包里面包含了原子变量(atomic variable)类,这些类用来实现数字和对象引用的原子状态转换。把long类型的计数器替换为AtomicLong类型的,我们可以确保所有访问计数器状态的操作都是原子的。

private AtomicLong count;
count.incrementAndGet();
 /**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final long incrementAndGet() {
        return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
    }

这样处理的自增操作就实现了线程安全的要求。

利用像AtomicLong这样已有的线程安全对象管理类的状态是非常实用的。

3、锁
使用线性安全对象管理类的使用毕竟有它的局限性,如果是唯一状态域,一个变量。这还可以,变量变多了线性安全对象管理类就显得很鸡肋了。是时候提出这个概念了。
Java提供了强制原子性的内置锁机制:synchronized块。一个synchronized块有两部分:锁对象的引用,以及这个锁保护的代码块。synchronized方法是对跨越了整个方法体的synchronized块的简短描述,至于synchronized方法的锁,就是该方法所在的对象本身。(静态的synchronized方法从class对象上获取锁。)

synchronized(lock){
//访问或修改被锁保护的共享状态
)

获得内部锁的唯一途径是:进入这个内部锁保护的同步块或方法。
同一时间,只能有一个线程可以运行特定锁保护的代码块,因此,由同一个锁保护的
synchronized块会各自原子地执行,不会相互干扰。
如果把整个方法都是使用synchronize同步机制的内部锁,这种行为过于极端,将导致性能问题。
重进入
请求是基于”每线程”,而不是“每调用”,这样就表示内部锁可以进入自己占有的锁,当计数为0表示未被占用,当计数递减至0,表示释放锁。
以下案例就是为了说明,内部锁的重进入带来的应用:

public class WidGet {
   public synchronized  void dosomething(){
      for(int i=0;i<1000;i++){
         System.out.println("");
      }
   }
}
public class LoggingWidGet extends WidGet {

   @Override
   public synchronized void dosomething() {
      System.out.println("520");
      super.dosomething();
   }
}

4、用锁来保护状态
仅仅用synchronized块包装复合操作是不够的;如果用同步来协调访问变量,每次访问变量时都需要同步。进一步讲,用锁来协调访问变量时,每次访问变量都需要用同一个锁。

补充:对象的串行(Serializing)访问与对象的序列化(seriaiization,将对象转化为字节流)
毫无关系;不间断访问意味肴线程依次独占地访问对象,而不是并发访问.

对象的内部锁与它的状态之间没有内在的关系。尽管大多数类普遍使用这样一种非常有效的锁机制:用对象的内部锁来保护所有的域,然而这并不是必需的。即使获得了与对象关联的锁也不能阻止其他线程访问这个对象―获得对象的锁后,唯一可以做的事情是阻止其他线程再获得相同的锁。作为一种便利,每个对象都有一个内部锁,所以你不需要显式地创建锁对象’。你可以构造自己的锁协议或同步策略,使你可以安全地访问共享状态,并且贯穿程序都始终如一地使用它们。

5、活跃度与性能
决定synchronized块的大小需要权衡各种设计要求,包括安全性(这是我们决不能妥协的)、简单性和性能。有时简单性与性能会彼此冲突,但是通常都能够从中找到一个合理的平衡。
通常简单性与性能之间是相互牵制的。实现一个同步策略时,不要过早地为了性能而栖牲简单性(这是对安全性潜在的妥协)。

给出的建议:
(1)当使用锁的时候,你应该清楚块中的代码的功能,以及它的执行过程是否会很耗时。无论是作运算密集型的操作,还是在执行一个可能存在潜在阻塞的操作,如果线程长时间地占有锁,就会引起活跃度与性能风险的问题。

(2)有些耗时的计算或操作,比如网络或控制台I/O,难以快速完成.执行这些操作期间不要占有锁

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值