java 并发编程之基础讲解(一)

本贴最后更新于 1606 天前,其中的信息可能已经水流花落

😫 看着身边的同学都在努力了 😩 好多东西都不会 😭 小王也要开始努力学习了 💪 接下来的日子又和并发编程杠上了 👿 好了 👊 开始搞起来 😇


唠唠并发

接下来呢,我给大家讲个故事,很久很久以前...好了不胡扯了。

再早期的计算机,它并没有现在这么强大,可以说是操作系统都没得,它从头到尾只执行一个程序,但由于这样自乱浪费太严重,慢慢的操作系统出现了,操作系统使得计算机每次能够运行多个程序,不同的程序都在单独的进程中运行,而不同的进程之间通过一些锁粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量以及文件等。

而对于进程而言还有另一个名词:线程。一个进程中可包含多个线程,线程间会共享进程范围内的资源。每个线程都会有各自的程序计数器(PC)、栈以及局部变量。线程还提供了一种直观的分解模式来充分利用多处理器中的硬件并行性,而再同一个程序中的多个线程可被调度到多个 CPU 上允许。同一个进程中的所有线程共享进程中的内存地址空间,这些线程都能访问相同的变量并在同一个堆上分配对象。

线程也被称为轻量级的进程,它是基本的调度单位(进程是资源分配的最小单位)多线程可以通过提高处理器的资源利用率来提高系统的吞吐量。多个线程有助于在单处理器系统上获得更高的吞吐率。但是如果程序中只有一个线程,那么最多只能在一个处理器上运行,而在双核的系统上则单线程将丢失一半的 CPU 资源,而加入在拥有 100 个处理器上系统运行,则将只能使用 1% 的资源。

多线程优势

  • 有效降低程序的开发和维护成本
  • 提升复杂应用程序的性能
  • 线程能够将大部分的异步工作流转化成为串行工作流
  • 可以降低代码的复杂度,使得代码更加容易编写、阅读和维护
  • 在 GUI(图形用户界面)应用程序中,可以提高用户界面的响应灵敏度
  • 服务器应用中,可以提高资源利用率以及系统吞吐率
  • 简化 JVM 的实现,垃圾收集器通常在一个或多个专门的线程中运行

线程的风险

安全性

在多线程的操作中,执行顺序是不可预测的,下图说明了线程执行的顺序不可预测


public class Tests{

    public static void main(String[] args) {
        NumberThread test1 = new NumberThread("test1");
        NumberThread test2 = new NumberThread("test2");
        test1.start();
        test2.start();
    }
}

class NumberThread extends Thread{
    String name;

    NumberThread(String name){
        this.name = name;
    }

    @Override
    public void run() {

        for (int i = 0; i < 5; i++)
        System.out.println(name+"   "+i);
    }

}

image.png

再来看一个对同一个数操作的例子:

public class Tests{

    public static void main(String[] args) {
        unsafe test1 = new unsafe("Thread-1");
        unsafe test2 = new unsafe("Thread-2");
        test1.start();
        test2.start();


    }
}

class unsafe extends Thread{
    static int num=10;
    String name;
    unsafe(String name){
        this.name = name;
    }
    @Override
    public void run() {
        while (num > 0){
            num--;
            System.out.println(name+"操作后的值为:"+num);
            try {
                sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

image.png

从图中不难看出有很多一样的值,由于执行的时机不对,两个线程在运行 run 的时候,就会得到相同的值。虽然是减一运算,但是实际他们包含了三个独立的操作:读 num,num-1,将计算机结果写入 num。由于运行时候多个线程之间交替运行,因此就会可能出现同时执行读的操作,从而得到相同的值。从上面也反应出一个问题:多线程是并发执行的,他们共享想通过的内存地址空间,他们可能会修改其他正在使用的变量。多线程的程序行为是不可预测的,线程由于无法预料数据的变化而发生错误,在我们使用多线程时,必须对共享变量的访问进行协同操作。

活跃性

在程序的运行中安全性是不可破坏的,但是程序中还可能出现另一个问题就是活跃性问题。什么是活跃性呢?就是当你某个操作无法执行下去的时候就会发生活跃性的问题,在串行的程序中活跃性问题的形式之一就是无意中造成的死循环。eg:当线程 A 在等待线程 B 释放其所持有的资源,而线程 B 永远不释放该资源,那么 A 将永远等待下去。

性能问题

性能问题包括很多:服务时间过长、响应不灵敏、吞吐率过低、资源消耗过高、可伸缩性较低等。在多线程中,线程调度器的临时挂起活跃线程并转而运行另一个线程时,就会频繁出现上下文切换的操作,这种操作为系统带来了极大的开销。

线程的安全性

我们在编写线程安全的代码核心是:要对状态访问操作进行关联,特别是对共享的和可变的状态的访问。 一个线程是否安全,取决于它能否被多个线程进行访问。要使线程安全需要采用同步机制来协同对象的可变状态的访问。JAVA 中主要的同步机制是 synchronized 关键字,它提供了一种独占锁的方式。

线程安全性定义:当多个线程访问某个类时,这个类始终能够表现除正确的行为,那么就称这个类是线程安全的。

PS:我们在学习 servlet 时,大多的 servlet 都是无状态的,无状态对象一定是线程安全的。 它极大的降低了在实现 servlet 线程安全性时的复杂性,只有当 servlet 在处理请求的时候需要保存一些信息,线程安全性才会出现问题。

原子性

  • 竞态条件

在程序执行的过程中,由于执行时许出现不正确的结果。当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。最常见的竞态条件类型是:“先检查后执行”的操作。

可能上面的看起来不那么容易理解,我们来看一个简单的例子:当有一天你和你女朋友去约会,然后你们约定去一个公园的咖啡店见面,结果你女朋友说:我们就在那个公园的咖啡店见。然后你们各自从家里出来前往见面的地点。但是当你去到之后你发现在东边有一个店,在西边有一个店,这时候给女朋友打电话他又没接。然后你去到东边的店看了看女朋友不在,然后你发现女朋友不再,这是你在想是迟到了呢?还是他在另一个店,然后你等了一会,发现还是没来,然后你就去西边的店找你女朋友,然后去到后还是没有,然后你又在想是不是她走了呢?还是她去找我了,然后你又去东边的店...一直往返,两人一直没有相见。而这个过程就是一个竞态条件。因为要获得正确的结果(与女朋友见面)必须取决于事件发生的时序。

  • 复合操作

为了确保线程的安全性,“先检查后执行”和“读取-修改-写入”等都是原子的。我们将这些操作称为符合操作:包含了一组必须以原子的方式执行的操作以确保线程的安全性。

加锁机制

  • 内置锁

在 JAVA 中提供一种内置锁的机制来支持原子性:同步代码块(同步代码块包括两部分:一个是作为锁的对象引用,另一个是作为有这个锁保护的代码块)JAVA 中内置锁相当于一种互斥锁。

以 synchronized 来修饰的方法就是一种横跨整个方法体的代码同步块。

每个 java 对象都可以用一个实现同步的锁,这些锁被称为内置锁或监视锁,线程在进入同步代码块之前会自动获得锁,并在退出同步代码块的时候释放锁。

  • 重入锁

内置锁是可重入的。“重入”意味着获取锁的操作粒度是“线程”而不是“调用”。重入的一种实现方法是为每个锁关联一个获取计数值和一个所有者线程。当某个线程请求一个未持有的锁时,JVM 将记下锁的持有者,并且将获得的锁计数值置为 1.如果同一个线程再次获得这个锁,计算器值递增,当线程退出同步代码块时,计数器值递减,计数器为 0 时,锁被释放。

接下来我们来看这样一个例子:


public class test{
    public synchronized void dosome{
        ...
    }
}
public class loggin extends test{
    public synchronized void dosome{
        ...
    super.dosome();
    }
}

从上面的例子可以看出,如果没有可重入锁,那么这段代码将会发生死锁。因为 loggin 与 test 都是 synchaonized 方法,因此每个 dosome 执行的时候都会获取 test 上的锁 他们锁住的对象是一致的 如果不是可重入锁那么 super.dosome()将无法获取 test。因为这个锁已经被持有。我们来看下面代码。

public class TestObject extends test {
    @Override
    public synchronized void doSomething() {
        super.doSomething();
        System.out.println("TestObject中的super: " + super.toString());
        System.out.println("TestObject中的 this: " + this);
    }

    public static void main(String[] args) {
        TestObject test = new TestObject();
        test.doSomething();
    }
}

class test {
    public synchronized void doSomething() {
        System.out.println("       test中的this: " + this);
    }
}

image.png

从运行的结果可以看到运行的对象都是同一个。

用锁来保护状态

锁能够保护代码路径以串行的形式访问。因此可以通过锁来构造一些协议以实现对共享状态的独占访问。在写程序时大多的类都将内置锁作为一种有效的锁机制,当时对象域并不一定要通过内置锁来保护。对象的内置锁与其状态之间没有内在的关联。一种常见的加锁就是:将所有的可变状态都封装在对象内部,通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得对象上不会发生并发访问。但是需要注意并不是所有的数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。

同时需要注意我们在避免竞态条件问题的时候,不能够浪涌 synchronized 关键字,防止出现程序种过多的同步。如果将每个方法都作为同步的方法,并不足以保证所有的符合操作都是原子的。

活跃性与性能

我们在使用同步策略的时候还需要注意它的活跃性与性能,例如对于 servlet 来说,如果通过 servlet 对象的内置锁对每个状态变量进行保护,他会对整个 service 方法进行同步,虽然能够保证线程安全性当时会付出很高的代价。由于 service 方法是一个 synchronized 方法,因此每次的请求都只能有一个线程可以执行。但是在负载过高的情况下,将会给用户带来糟糕的用户体验,因为每一个请求都需要等待前一个请求执行完成。那么怎么办呢?我们可以通过缩小代码块的作用范围,从而在确保 servlet 的并发性的同时维护线程的安全。

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

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...