面试:如何保证程序线程安全?

644 阅读3分钟

思考:

线程不安全是因为有多个线程访问一块共享的可变的内存资源。那么单线程肯定线程安全,不使用共享的内存资源线程安全,不使用可变的内存资源线程安全,这就是我们解决此问题的核心思想。

单线程程序

这个角度看似有点废话而往往被忽略,但是有时候解决实际问题的时候使用往往有奇效,简单而直接。

不使用共享的内存资源线程安全

public int add(int a, int b) {
        return a + b + 10;
    }

可重入函数,就是不使用共享内存资源的例子。我们可以看下“可重入函数”的定义,可以被中断的函数。就是说,你可以在这个函数执行的任何时候中断他的运行,在任务调度下去执行另外一段代码而不会出现什么错误,这是因为他没有使用任何外部变量。所以函数式编程在线程安全上有先天的优势。

static class Thread2 extends Thread {

        static final ThreadLocal<String> id = new ThreadLocal<>();

        @Override
        public void run() {
            String i = UUID.randomUUID().toString();
            id.set(i);

            System.out.println(Thread.currentThread().getName() + "-" + id.get());
        }
    }

使用ThreadLocal存储变量,ThreadLocal内部有个ThreadLocalMap存储变量,他又是和Thread实例绑定在一起,这样就做到了线程间的变量隔离。打个不完美的比喻,这个一块不会被刷新到“主内存”的“临时内存”,线程独享。

不使用可变的内存资源线程安全

不使用可变内存?什么情况下能不使用可变内存呢?其实大部分场景并不是不使用可变内存,而是在使用时保证该内存不可变。这就需要做到以下三点:

  • 内存可见性
  • 操作原子性
  • 指令重排序

内存可见性

从java内存模型中可知,线程操作都是临时工作内存,然后根据jvm工作机制在合适的时机将临时内存同步到主内存中,这中间存在时间差,如果做不到内存可见性,程序就错乱了。使用volatile,final,锁可以保证可见性。

class WorkThread1 extends Thread {

        volatile boolean isStop = false;

        @Override
        public void run() {
            for (int i = 0; i < 100000; i++) {
                if (isStop)
                    break;
                System.out.println("i = " + i);
            }
        }
    }

上面就是使用volatile保证了内存的可见性,保证在外部改变了isStop的值,线程内部能马上知晓。

public class Test {
    
    int a;
    final int b;
    
    public Test(int a,int b){
        this.a = a;
        this.b = b;
    }
}

当我调用了Test(4,5),我再输出打印a,b的值,b一定能打印出5,而a并不一定。这其中涉及到了指令的重排序。因为指令被重排序了,虽然Test实例已经生成,但是a并没有马上赋值,而当他被赋值后,外部并不马上可知。

使用锁保证可见性的原理,就是加锁的时候,别人访问不了,释放的时候,强制刷新主内存。

指令重排序

volatile,final可以保证指令重排序。

操作原子性

操作原子性。比如a++就不是原子操作,他的指令分三步,第一步创建临时变量,第二步临时变量加1,第三步将临时变量赋值给a。当有两个以上的线程操作同一个变量,肯定就错乱了。那么怎么办呢?

  • 加锁
  • 使用CAS
  • 使用原子数值类型
  • 使用原子属性更新器