文章目录

  • Java并发编程实战之基础知识
  • 一、线程安全性
  • 1、定义
  • 2、无状态对象和有状态对象
  • 二、原子性
  • 1、竞态条件
  • 2、延迟初始化中的竞态条件
  • 3、复合操作(如AtomicLong)
  • 三、加锁机制
  • 1、加锁问题的引出
  • 2、内置锁
  • 3、重入锁
  • 重入锁的一种实现方法
  • 4、用锁来保护状态
  • 5、活跃性和性能


Java并发编程实战之基础知识

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是共享的和可变的状态的访问。
要使得对象是线程安全的,需要采用同步的机制来协同对对象可变状态的访问。Java的主要的同步机制是synchronized关键字,它提供了一种独占的加锁方式,但是"同步"这个术语还包括volatile类型的变量、显式锁以及原子变量。

一、线程安全性

1、定义

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

2、无状态对象和有状态对象

有状态对象:
有状态就是有数据存储功能。有状态对象(Stateful Bean),就是有实例变量的对象,可以保存数据,是非线程安全的。在不同方法调用间不保留任何状态。
其实就是有数据成员的对象。
无状态对象:
无状态就是一次操作,不能保存数据。无状态对象(Stateless Bean),就是没有实例变量的对象。不能保存数据,是不变类,是线程安全的。
具体来说就是只有方法没有数据成员的对象,或者有数据成员但是数据成员是可读的对象。

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

二、原子性

虽然递增操作count++是一种紧凑的语法,使其看上去是一个操作,但是这个操作并非原子的。这是一个读取-修改-写入的操作序列。

import java.util.concurrent.TimeUnit;

class MyData1
{
    volatile int number = 0;
    public void addPlusPlus()
    {

        number++;
    }
}

/**
 * 验证volatile不保证原子性能。
 *  
 */
public class VolatileDemo2
{
    public static void main(String[] args)
    {
        MyData1 myData1 = new MyData1();
        for(int i=1;i<=20;i++)
        {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData1.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
        while (Thread.activeCount()>2) //因为一个是main还有一个是gc线程
        {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+"\t finally number value"+myData1.number);
    }

}

1、竞态条件

由于不恰当的执行顺序而出现不正确的结果。也就是说当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确当结果要决于运气。
最常见的竞态条件类型就是先检查后执行,如懒汉式。

2、延迟初始化中的竞态条件

@NotThreadSafe
public class LazyInitRace
{
	private ExpensiveObject instance = null;
	public ExpensiveObject getInstance()
	{
		if(instance==null)
		{
			instance = new ExpensiveObject();
		}
		return instance;
	}
}

3、复合操作(如AtomicLong)

为了确保线程安全,“先检查后执行”如延迟初始化和“读取-修改-写入”如递增操作等必须是原子的,我们将“先检查后执行”以及“读取-修改-写入”等操作统称为复合操作。
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过使用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

class MyData1
{
    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic()
    {
        atomicInteger.getAndIncrement();
    }
}

/**
 * 验证AtomicInteger保证原子性
 */
public class VolatileDemo2
{
    public static void main(String[] args)
    {
        MyData1 myData1 = new MyData1();
        for(int i=1;i<=20;i++)
        {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData1.addAtomic();
                }
            }, String.valueOf(i)).start();
        }
        while (Thread.activeCount()>2) //因为一个是main还有一个是gc线程
        {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+"\t finally number value"+myData1.atomicInteger);
    }

}

三、加锁机制

1、加锁问题的引出

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

假设我们想提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因式分解时,可以直接使用上一次的计算结果,而无须重新计算。要实现该缓存策略,需要保存两个状态:最近执行因分解的数值以及分解结果。
这里可以使用AtomicReference来管理最近执行因式分解的数值以及其分解结果吗?
AtomicLong是一种替代long类型的整数的线程安全类,AtomicReference是一种替代对象引用的线程安全类。

@NotThreadSafe
    public class UnsafeCachingFactorizer implements Servlet
    {
        private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
        private final AtomicReference<BigInteger[]> AtomicReference = new AtomicReference<BigInteger>();
        public void service(ServletRequest req,ServletResponse resp)
        {
            BigInteger i = extractFromRequest(req);
            if(i.equals(lastNumber.get()))
                encodeIntoResponse(resp,lastFactors.get());
            else
            {
                BigInteger[] factors = factor(i);
                lastNumber.set(i);
                lastFactors.set(factors);
                encodeIntoResponse(resp,factors);
            }
            
        }
    }

但是上面的代码还是存在竞态条件,这可能产生错误的结果。

**通过上面例子的线程不安全问题得出的结论:要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。上面的代码就是没有更新所有相关的状态变量。**

2、内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)
同步代码块包含两部分:
一个作为锁的对象引用
一个作为由这个锁保护的代码块

以关键字Synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该代码块的锁就是方法调用所
在的对象,静态的synchronized方法以Class对象作为锁。

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

每个Java对象都可以作为锁,这些锁被称为内置锁或者监视器锁"。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。

Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。

如果使用synchronized修饰的方法,虽然编程线程安全的,但是在这样的状态下性能十分差。

3、重入锁

当某一个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁可以重入的因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功,“重入"意味获取锁的操作的粒度是线程而不是"调用”。

重入锁的一种实现方法

重入的一种实现方法是为每一个锁关联一个获取计数值和一个所有者线程,当计数值为0时,这个锁默认没有被任何线程持有,当线程持有者请求一个没有被持有的锁,JVM会记录下持有者,并会获取计数值设置为1,再获取,再递增,而当线程退出同步代码块时,计数器会相应的减少,当计数值为0时,这个锁将被释放。

package com.test.reen;

// 演示可重入锁是什么意思,可重入,就是可以重复获取相同的锁,synchronized和ReentrantLock都是可重入的
// 可重入降低了编程复杂性
public class WhatReentrant {
	public static void main(String[] args) {
		new Thread(new Runnable() {
			@Override
			public void run() {
				synchronized (this) {
					System.out.println("第1次获取锁,这个锁是:" + this);
					int index = 1;
					while (true) {
						synchronized (this) {
							System.out.println("第" + (++index) + "次获取锁,这个锁是:" + this);
						}
						if (index == 10) {
							break;
						}
					}
				}
			}
		}).start();
	}
}

4、用锁来保护状态

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

每个共享的和可变的变量都应该只由一个锁来保护,从而使得维护人员知道是哪个锁

5、活跃性和性能

对方法加synchronized修饰,虽然达到并发安全的效果,但是效率很差,可以通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性,要确保同步代码块不要太小,并不要将本应是原子操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去。

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