首页> 标签> 编译器
"编译器"
共 12191 条结果
全部 问答 文章 公开课 课程 电子书 技术圈 体验
写给大忙人看的进程和线程(五)
Pthreads 中的互斥量Pthreads 提供了一些功能用来同步线程。最基本的机制是使用互斥量变量,可以锁定和解锁,用来保护每个关键区域。希望进入关键区域的线程首先要尝试获取 mutex。如果 mutex 没有加锁,线程能够马上进入并且互斥量能够自动锁定,从而阻止其他线程进入。如果 mutex 已经加锁,调用线程会阻塞,直到 mutex 解锁。如果多个线程在相同的互斥量上等待,当互斥量解锁时,只有一个线程能够进入并且重新加锁。这些锁并不是必须的,程序员需要正确使用它们。下面是与互斥量有关的函数调用像我们想象中的一样,mutex 能够被创建和销毁,扮演这两个角色的分别是 Phread_mutex_init 和 Pthread_mutex_destroy。mutex 也可以通过 Pthread_mutex_lock 来进行加锁,如果互斥量已经加锁,则会阻塞调用者。还有一个调用Pthread_mutex_trylock 用来尝试对线程加锁,当 mutex 已经被加锁时,会返回一个错误代码而不是阻塞调用者。这个调用允许线程有效的进行忙等。最后,Pthread_mutex_unlock 会对 mutex 解锁并且释放一个正在等待的线程。除了互斥量以外,Pthreads 还提供了第二种同步机制: 条件变量(condition variables) 。mutex 可以很好的允许或阻止对关键区域的访问。条件变量允许线程由于未满足某些条件而阻塞。绝大多数情况下这两种方法是一起使用的。下面我们进一步来研究线程、互斥量、条件变量之间的关联。下面再来重新认识一下生产者和消费者问题:一个线程将东西放在一个缓冲区内,由另一个线程将它们取出。如果生产者发现缓冲区没有空槽可以使用了,生产者线程会阻塞起来直到有一个线程可以使用。生产者使用 mutex 来进行原子性检查从而不受其他线程干扰。但是当发现缓冲区已经满了以后,生产者需要一种方法来阻塞自己并在以后被唤醒。这便是条件变量做的工作。下面是一些与条件变量有关的最重要的 pthread 调用上表中给出了一些调用用来创建和销毁条件变量。条件变量上的主要属性是 Pthread_cond_wait 和 Pthread_cond_signal。前者阻塞调用线程,直到其他线程发出信号为止(使用后者调用)。阻塞的线程通常需要等待唤醒的信号以此来释放资源或者执行某些其他活动。只有这样阻塞的线程才能继续工作。条件变量允许等待与阻塞原子性的进程。Pthread_cond_broadcast 用来唤醒多个阻塞的、需要等待信号唤醒的线程。需要注意的是,条件变量(不像是信号量)不会存在于内存中。如果将一个信号量传递给一个没有线程等待的条件变量,那么这个信号就会丢失,这个需要注意下面是一个使用互斥量和条件变量的例子#include <stdio.h> #include <pthread.h> /* 需要生产的数量 */ #define MAX 1000000000 pthread_mutex_t the_mutex; /* 使用信号量 */ pthread_cond_t condc,condp; int buffer = 0; /* 生产数据 */ void *producer(void *ptr){ int i; for(int i = 0;i <= MAX;i++){ /* 缓冲区独占访问,也就是使用 mutex 获取锁 */ pthread_mutex_lock(&the_mutex); while(buffer != 0){ pthread_cond_wait(&condp,&the_mutex); } /* 把他们放在缓冲区中 */ buffer = i; /* 唤醒消费者 */ pthread_cond_signal(&condc); /* 释放缓冲区 */ pthread_mutex_unlock(&the_mutex); } pthread_exit(0); } /* 消费数据 */ void *consumer(void *ptr){ int i; for(int i = 0;i <= MAX;i++){ /* 缓冲区独占访问,也就是使用 mutex 获取锁 */ pthread_mutex_lock(&the_mutex); while(buffer == 0){ pthread_cond_wait(&condc,&the_mutex); } /* 把他们从缓冲区中取出 */ buffer = 0; /* 唤醒生产者 */ pthread_cond_signal(&condp); /* 释放缓冲区 */ pthread_mutex_unlock(&the_mutex); } pthread_exit(0); }管程为了能够编写更加准确无误的程序,Brinch Hansen 和 Hoare 提出了一个更高级的同步原语叫做 管程(monitor)。他们两个人的提案略有不同,通过下面的描述你就可以知道。管程是程序、变量和数据结构等组成的一个集合,它们组成一个特殊的模块或者包。进程可以在任何需要的时候调用管程中的程序,但是它们不能从管程外部访问数据结构和程序。下面展示了一种抽象的,类似 Pascal 语言展示的简洁的管程。不能用 C 语言进行描述,因为管程是语言概念而 C 语言并不支持管程。monitor example integer i; condition c; procedure producer(); ... end; procedure consumer(); . end;end monitor;管程有一个很重要的特性,即在任何时候管程中只能有一个活跃的进程,这一特性使管程能够很方便的实现互斥操作。管程是编程语言的特性,所以编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。通常情况下,当进程调用管程中的程序时,该程序的前几条指令会检查管程中是否有其他活跃的进程。如果有的话,调用进程将被挂起,直到另一个进程离开管程才将其唤醒。如果没有活跃进程在使用管程,那么该调用进程才可以进入。进入管程中的互斥由编译器负责,但是一种通用做法是使用 互斥量(mutex) 和 二进制信号量(binary semaphore)。由于编译器而不是程序员在操作,因此出错的几率会大大降低。在任何时候,编写管程的程序员都无需关心编译器是如何处理的。他只需要知道将所有的临界区转换成为管程过程即可。绝不会有两个进程同时执行临界区中的代码。即使管程提供了一种简单的方式来实现互斥,但在我们看来,这还不够。因为我们还需要一种在进程无法执行被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放在管程程序中,但是生产者在发现缓冲区满的时候该如何阻塞呢?解决的办法是引入条件变量(condition variables) 以及相关的两个操作 wait 和 signal。当一个管程程序发现它不能运行时(例如,生产者发现缓冲区已满),它会在某个条件变量(如 full)上执行 wait 操作。这个操作造成调用进程阻塞,并且还将另一个以前等在管程之外的进程调入管程。在前面的 pthread 中我们已经探讨过条件变量的实现细节了。另一个进程,比如消费者可以通过执行 signal 来唤醒阻塞的调用进程。Brinch Hansen 和 Hoare 在对进程唤醒上有所不同,Hoare 建议让新唤醒的进程继续运行;而挂起另外的进程。而 Brinch Hansen 建议让执行 signal 的进程必须退出管程,这里我们采用 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。如果在一个条件变量上有若干进程都在等待,则在对该条件执行 signal 操作后,系统调度程序只能选择其中一个进程恢复运行。顺便提一下,这里还有上面两位教授没有提出的第三种方式,它的理论是让执行 signal 的进程继续运行,等待这个进程退出管程时,其他进程才能进入管程。条件变量不是计数器。条件变量也不能像信号量那样积累信号以便以后使用。所以,如果向一个条件变量发送信号,但是该条件变量上没有等待进程,那么信号将会丢失。也就是说,wait 操作必须在 signal 之前执行。下面是一个使用 Pascal 语言通过管程实现的生产者-消费者问题的解法monitor ProducerConsumer condition full,empty; integer count; procedure insert(item:integer); begin if count = N then wait(full); insert_item(item); count := count + 1; if count = 1 then signal(empty); end; function remove:integer; begin if count = 0 then wait(empty); remove = remove_item; count := count - 1; if count = N - 1 then signal(full); end; count := 0;end monitor;procedure producer;begin while true do begin item = produce_item; ProducerConsumer.insert(item); endend;procedure consumer;begin while true do begin item = ProducerConsumer.remove; consume_item(item); endend;读者可能觉得 wait 和 signal 操作看起来像是前面提到的 sleep 和 wakeup ,而且后者存在严重的竞争条件。它们确实很像,但是有个关键的区别:sleep 和 wakeup 之所以会失败是因为当一个进程想睡眠时,另一个进程试图去唤醒它。使用管程则不会发生这种情况。管程程序的自动互斥保证了这一点,如果管程过程中的生产者发现缓冲区已满,它将能够完成 wait 操作而不用担心调度程序可能会在 wait 完成之前切换到消费者。甚至,在 wait 执行完成并且把生产者标志为不可运行之前,是不会允许消费者进入管程的。尽管类 Pascal 是一种想象的语言,但还是有一些真正的编程语言支持,比如 Java (终于轮到大 Java 出场了),Java 是能够支持管程的,它是一种 面向对象的语言,支持用户级线程,还允许将方法划分为类。只要将关键字 synchronized 关键字加到方法中即可。Java 能够保证一旦某个线程执行该方法,就不允许其他线程执行该对象中的任何 synchronized 方法。没有关键字 synchronized ,就不能保证没有交叉执行。下面是 Java 使用管程解决的生产者-消费者问题public class ProducerConsumer { // 定义缓冲区大小的长度 static final int N = 100; // 初始化一个新的生产者线程 static Producer p = new Producer(); // 初始化一个新的消费者线程 static Consumer c = new Consumer(); // 初始化一个管程 static Our_monitor mon = new Our_monitor(); // run 包含了线程代码 static class Producer extends Thread{ public void run(){ int item; // 生产者循环 while(true){ item = produce_item(); mon.insert(item); } } // 生产代码 private int produce_item(){...} } // run 包含了线程代码 static class consumer extends Thread { public void run( ) { int item; while(true){ item = mon.remove(); consume_item(item); } } // 消费代码 private int produce_item(){...} } // 这是管程 static class Our_monitor { private int buffer[] = new int[N]; // 计数器和索引 private int count = 0,lo = 0,hi = 0; private synchronized void insert(int val){ if(count == N){ // 如果缓冲区是满的,则进入休眠 go_to_sleep(); } // 向缓冲区插入内容 buffer[hi] = val; // 找到下一个槽的为止 hi = (hi + 1) % N; // 缓冲区中的数目自增 1 count = count + 1; if(count == 1){ // 如果消费者睡眠,则唤醒 notify(); } } private synchronized void remove(int val){ int val; if(count == 0){ // 缓冲区是空的,进入休眠 go_to_sleep(); } // 从缓冲区取出数据 val = buffer[lo]; // 设置待取出数据项的槽 lo = (lo + 1) % N; // 缓冲区中的数据项数目减 1 count = count - 1; if(count = N - 1){ // 如果生产者睡眠,唤醒它 notify(); } return val; } private void go_to_sleep() { try{ wait( ); }catch(Interr uptedExceptionexc) {}; } }}上面的代码中主要设计四个类,外部类(outer class) ProducerConsumer 创建并启动两个线程,p 和 c。第二个类和第三个类 Producer 和 Consumer 分别包含生产者和消费者代码。最后,Our_monitor 是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据。在前面的所有例子中,生产者和消费者线程在功能上与它们是相同的。生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环用于从缓冲区取出数据并完成一系列工作。程序中比较耐人寻味的就是 Our_monitor 了,它包含缓冲区、管理变量以及两个同步方法。当生产者在 insert 内活动时,它保证消费者不能在 remove 方法中运行,从而保证更新变量以及缓冲区的安全性,并且不用担心竞争条件。变量 count 记录在缓冲区中数据的数量。变量 lo 是缓冲区槽的序号,指出将要取出的下一个数据项。类似地,hi 是缓冲区中下一个要放入的数据项序号。允许 lo = hi,含义是在缓冲区中有 0 个或 N 个数据。Java 中的同步方法与其他经典管程有本质差别:Java 没有内嵌的条件变量。然而,Java 提供了 wait 和 notify 分别与 sleep 和 wakeup 等价。通过临界区自动的互斥,管程比信号量更容易保证并行编程的正确性。但是管程也有缺点,我们前面说到过管程是一个编程语言的概念,编译器必须要识别管程并用某种方式对其互斥作出保证。C、Pascal 以及大多数其他编程语言都没有管程,所以不能依靠编译器来遵守互斥规则。与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问共享内存的一个或多个 CPU 上的互斥问题的。通过将信号量放在共享内存中并用 TSL 或 XCHG 指令来保护它们,可以避免竞争。但是如果是在分布式系统中,可能同时具有多个 CPU 的情况,并且每个 CPU 都有自己的私有内存呢,它们通过网络相连,那么这些原语将会失效。因为信号量太低级了,而管程在少数几种编程语言之外无法使用,所以还需要其他方法。消息传递上面提到的其他方法就是 消息传递(messaage passing)。这种进程间通信的方法使用两个原语 send 和 receive ,它们像信号量而不像管程,是系统调用而不是语言级别。示例如下send(destination, &message);receive(source, &message);send 方法用于向一个给定的目标发送一条消息,receive 从一个给定的源接受一条消息。如果没有消息,接受者可能被阻塞,直到接受一条消息或者带着错误码返回。消息传递系统的设计要点消息传递系统现在面临着许多信号量和管程所未涉及的问题和设计难点,尤其对那些在网络中不同机器上的通信状况。例如,消息有可能被网络丢失。为了防止消息丢失,发送方和接收方可以达成一致:一旦接受到消息后,接收方马上回送一条特殊的 确认(acknowledgement) 消息。如果发送方在一段时间间隔内未收到确认,则重发消息。现在考虑消息本身被正确接收,而返回给发送着的确认消息丢失的情况。发送者将重发消息,这样接受者将收到两次相同的消息。对于接收者来说,如何区分新的消息和一条重发的老消息是非常重要的。通常采用在每条原始消息中嵌入一个连续的序号来解决此问题。如果接受者收到一条消息,它具有与前面某一条消息一样的序号,就知道这条消息是重复的,可以忽略。消息系统还必须处理如何命名进程的问题,以便在发送或接收调用中清晰的指明进程。身份验证(authentication) 也是一个问题,比如客户端怎么知道它是在与一个真正的文件服务器通信,从发送方到接收方的信息有可能被中间人所篡改。用消息传递解决生产者-消费者问题现在我们考虑如何使用消息传递来解决生产者-消费者问题,而不是共享缓存。下面是一种解决方式/ buffer 中槽的数量 /#define N 100 void producer(void){ int item; / buffer 中槽的数量 / message m; while(TRUE){ / 生成放入缓冲区的数据 / item = produce_item(); / 等待消费者发送空缓冲区 / receive(consumer,&m); / 建立一个待发送的消息 / build_message(&m,item); / 发送给消费者 / send(consumer,&m); }}void consumer(void){ int item,i; message m; / 循环N次 / for(int i = 0;i < N;i++){ / 发送N个缓冲区 / send(producer,&m); } while(TRUE){ / 接受包含数据的消息 / receive(producer,&m); / 将数据从消息中提取出来 / item = extract_item(&m); / 将空缓冲区发送回生产者 / send(producer,&m); / 处理数据 / consume_item(item); }}假设所有的消息都有相同的大小,并且在尚未接受到发出的消息时,由操作系统自动进行缓冲。在该解决方案中共使用 N 条消息,这就类似于一块共享内存缓冲区的 N 个槽。消费者首先将 N 条空消息发送给生产者。当生产者向消费者传递一个数据项时,它取走一条空消息并返回一条填充了内容的消息。通过这种方式,系统中总的消息数量保持不变,所以消息都可以存放在事先确定数量的内存中。如果生产者的速度要比消费者快,则所有的消息最终都将被填满,等待消费者,生产者将被阻塞,等待返回一条空消息。如果消费者速度快,那么情况将正相反:所有的消息均为空,等待生产者来填充,消费者将被阻塞,以等待一条填充过的消息。消息传递的方式有许多变体,下面先介绍如何对消息进行 编址。一种方法是为每个进程分配一个唯一的地址,让消息按进程的地址编址。另一种方式是引入一个新的数据结构,称为 信箱(mailbox),信箱是一个用来对一定的数据进行缓冲的数据结构,信箱中消息的设置方法也有多种,典型的方法是在信箱创建时确定消息的数量。在使用信箱时,在 send 和 receive 调用的地址参数就是信箱的地址,而不是进程的地址。当一个进程试图向一个满的信箱发送消息时,它将被挂起,直到信箱中有消息被取走,从而为新的消息腾出地址空间。屏障最后一个同步机制是准备用于进程组而不是进程间的生产者-消费者情况的。在某些应用中划分了若干阶段,并且规定,除非所有的进程都就绪准备着手下一个阶段,否则任何进程都不能进入下一个阶段,可以通过在每个阶段的结尾安装一个 屏障(barrier) 来实现这种行为。当一个进程到达屏障时,它会被屏障所拦截,直到所有的屏障都到达为止。屏障可用于一组进程同步,如下图所示在上图中我们可以看到,有四个进程接近屏障,这意味着每个进程都在进行运算,但是还没有到达每个阶段的结尾。过了一段时间后,A、B、D 三个进程都到达了屏障,各自的进程被挂起,但此时还不能进入下一个阶段呢,因为进程 B 还没有执行完毕。结果,当最后一个 C 到达屏障后,这个进程组才能够进入下一个阶段。避免锁:读-复制-更新最快的锁是根本没有锁。问题在于没有锁的情况下,我们是否允许对共享数据结构的并发读写进行访问。答案当然是不可以。假设进程 A 正在对一个数字数组进行排序,而进程 B 正在计算其平均值,而此时你进行 A 的移动,会导致 B 会多次读到重复值,而某些值根本没有遇到过。然而,在某些情况下,我们可以允许写操作来更新数据结构,即便还有其他的进程正在使用。窍门在于确保每个读操作要么读取旧的版本,要么读取新的版本,例如下面的树上面的树中,读操作从根部到叶子遍历整个树。加入一个新节点 X 后,为了实现这一操作,我们要让这个节点在树中可见之前使它"恰好正确":我们对节点 X 中的所有值进行初始化,包括它的子节点指针。然后通过原子写操作,使 X 称为 A 的子节点。所有的读操作都不会读到前后不一致的版本在上面的图中,我们接着移除 B 和 D。首先,将 A 的左子节点指针指向 C 。所有原本在 A 中的读操作将会后续读到节点 C ,而永远不会读到 B 和 D。也就是说,它们将只会读取到新版数据。同样,所有当前在 B 和 D 中的读操作将继续按照原始的数据结构指针并且读取旧版数据。所有操作均能正确运行,我们不需要锁住任何东西。而不需要锁住数据就能够移除 B 和 D 的主要原因就是 读-复制-更新(Ready-Copy-Update,RCU),将更新过程中的移除和再分配过程分离开。调度当一个计算机是多道程序设计系统时,会频繁的有很多进程或者线程来同时竞争 CPU 时间片。当两个或两个以上的进程/线程处于就绪状态时,就会发生这种情况。如果只有一个 CPU 可用,那么必须选择接下来哪个进程/线程可以运行。操作系统中有一个叫做 调度程序(scheduler) 的角色存在,它就是做这件事儿的,该程序使用的算法叫做 调度算法(scheduling algorithm) 。尽管有一些不同,但许多适用于进程调度的处理方法同样也适用于线程调度。当内核管理线程的时候,调度通常会以线程级别发生,很少或者根本不会考虑线程属于哪个进程。下面我们会首先专注于进程和线程的调度问题,然后会明确的介绍线程调度以及它产生的问题。调度介绍让我们回到早期以磁带上的卡片作为输入的批处理系统的时代,那时候的调度算法非常简单:依次运行磁带上的每一个作业。对于多道程序设计系统,会复杂一些,因为通常会有多个用户在等待服务。一些大型机仍然将 批处理和 分时服务结合使用,需要调度程序决定下一个运行的是一个批处理作业还是终端上的用户。由于在这些机器中 CPU 是稀缺资源,所以好的调度程序可以在提高性能和用户的满意度方面取得很大的成果。进程行为几乎所有的进程(磁盘或网络)I/O 请求和计算都是交替运行的如上图所示,CPU 不停顿的运行一段时间,然后发出一个系统调用等待 I/O 读写文件。完成系统调用后,CPU 又开始计算,直到它需要读更多的数据或者写入更多的数据为止。当一个进程等待外部设备完成工作而被阻塞时,才是 I/O 活动。上面 a 是 CPU 密集型进程;b 是 I/O 密集型进程进程,a 因为在计算的时间上花费时间更长,因此称为计算密集型(compute-bound) 或者 CPU 密集型(CPU-bound),b 因为I/O 发生频率比较快因此称为 I/O 密集型(I/O-bound)。计算密集型进程有较长的 CPU 集中使用和较小频度的 I/O 等待。I/O 密集型进程有较短的 CPU 使用时间和较频繁的 I/O 等待。注意到上面两种进程的区分关键在于 CPU 的时间占用而不是 I/O 的时间占用。I/O 密集型的原因是因为它们没有在 I/O 之间花费更多的计算、而不是 I/O 请求时间特别长。无论数据到达后需要花费多少时间,它们都需要花费相同的时间来发出读取磁盘块的硬件请求。值得注意的是,随着 CPU 的速度越来越快,更多的进程倾向于 I/O 密集型。这种情况出现的原因是 CPU 速度的提升要远远高于硬盘。这种情况导致的结果是,未来对 I/O 密集型进程的调度处理似乎更为重要。这里的基本思想是,如果需要运行 I/O 密集型进程,那么就应该让它尽快得到机会,以便发出磁盘请求并保持磁盘始终忙碌。何时调度第一个和调度有关的问题是何时进行调度决策。存在着需要调度处理的各种情形。首先,在创建一个新进程后,需要决定是运行父进程还是子进程。因为二者的进程都处于就绪态下,这是正常的调度决策,可以任意选择,也就是说,调度程序可以任意的选择子进程或父进程开始运行。第二,在进程退出时需要作出调度决定。因为此进程不再运行(因为它将不再存在),因此必须从就绪进程中选择其他进程运行。如果没有进程处于就绪态,系统提供的空闲进程通常会运行什么是空闲进程空闲进程(system-supplied idle process) 是 Microsoft 公司 windows 操作系统带有的系统进程,该进程是在各个处理器上运行的单个线程,它唯一的任务是在系统没有处理其他线程时占用处理器时间。System Idle Process 并不是一个真正的进程,它是核心虚拟出来的,多任务操作系统都存在。在没有可用的进程时,系统处于空运行状态,此时就是System Idle Process 在正在运行。你可以简单的理解成,它代表的是 CPU 的空闲状态,数值越大代表处理器越空闲,可以通过 Windows 任务管理器查看 Windows 中的 CPU 利用率第三种情况是,当进程阻塞在 I/O 、信号量或其他原因时,必须选择另外一个进程来运行。有时,阻塞的原因会成为选择进程运行的关键因素。例如,如果 A 是一个重要进程,并且它正在等待 B 退出关键区域,让 B 退出关键区域从而使 A 得以运行。但是调度程序一般不会对这种情况进行考量。第四点,当 I/O 中断发生时,可以做出调度决策。如果中断来自 I/O 设备,而 I/O 设备已经完成了其工作,那么那些等待 I/O 的进程现在可以继续运行。由调度程序来决定是否准备运行新的进程还是重新运行已经中断的进程。如果硬件时钟以 50 或 60 Hz 或其他频率提供周期性中断,可以在每个时钟中断或第 k 个时钟中断处做出调度决策。根据如何处理时钟中断可以把调度算法可以分为两类。非抢占式(nonpreemptive) 调度算法挑选一个进程,让该进程运行直到被阻塞(阻塞在 I/O 上或等待另一个进程),或者直到该进程自动释放 CPU。即使该进程运行了若干个小时后,它也不会被强制挂起。这样会在时钟中断发生时不会进行调度。在处理完时钟中断后,如果没有更高优先级的进程等待,则被中断的进程会继续执行。另外一种情况是 抢占式 调度算法,它会选择一个进程,并使其在最大固定时间内运行。如果在时间间隔结束后仍在运行,这个进程会被挂起,调度程序会选择其他进程来运行(前提是存在就绪进程)。进行抢占式调度需要在时间间隔结束时发生时钟中断,以将 CPU 的控制权交还给调度程序。如果没有可用的时钟,那么非抢占式就是唯一的选择。调度算法的分类毫无疑问,不同的环境下需要不同的调度算法。之所以出现这种情况,是因为不同的应用程序和不同的操作系统有不同的目标。也就是说,在不同的系统中,调度程序的优化也是不同的。这里有必要划分出三种环境批处理(Batch)交互式(Interactive)实时(Real time)批处理系统广泛应用于商业领域,比如用来处理工资单、存货清单、账目收入、账目支出、利息计算、索赔处理和其他周期性作业。在批处理系统中,一般会选择使用非抢占式算法或者周期性比较长的抢占式算法。这种方法可以减少线程切换因此能够提升性能。在交互式用户环境中,为了避免一个进程霸占 CPU 拒绝为其他进程服务,所以需要抢占式算法。即使没有进程有意要一直运行下去,但是,由于某个进程出现错误也有可能无限期的排斥其他所有进程。为了避免这种情况,抢占式也是必须的。服务器也属于此类别,因为它们通常为多个(远程)用户提供服务,而这些用户都非常着急。计算机用户总是很忙。在实时系统中,抢占有时是不需要的,因为进程知道自己可能运行不了很长时间,通常很快的做完自己的工作并阻塞。实时系统与交互式系统的差别是,实时系统只运行那些用来推进现有应用的程序,而交互式系统是通用的,它可以运行任意的非协作甚至是有恶意的程序。调度算法的目标为了设计调度算法,有必要考虑一下什么是好的调度算法。有一些目标取决于环境(批处理、交互式或者实时)蛋大部分是适用于所有情况的,下面是一些需要考量的因素,我们会在下面一起讨论。所有系统在所有的情况中,公平是很重要的。对一个进程给予相较于其他等价的进程更多的 CPU 时间片对其他进程来说是不公平的。当然,不同类型的进程可以采用不同的处理方式。与公平有关的是系统的强制执行,什么意思呢?如果某公司的薪资发放系统计划在本月的15号,那么碰上了疫情大家生活都很拮据,此时老板说要在14号晚上发放薪资,那么调度程序必须强制使进程执行 14 号晚上发放薪资的策略。另一个共同的目标是保持系统的所有部分尽可能的忙碌。如果 CPU 和所有的 I/O 设备能够一直运行,那么相对于让某些部件空转而言,每秒钟就可以完成更多的工作。例如,在批处理系统中,调度程序控制哪个作业调入内存运行。在内存中既有一些 CPU 密集型进程又有一些 I/O 密集型进程是一个比较好的想法,好于先调入和运行所有的 CPU 密集型作业,然后在它们完成之后再调入和运行所有 I/O 密集型作业的做法。使用后者这种方式会在 CPU 密集型进程启动后,争夺 CPU ,而磁盘却在空转,而当 I/O 密集型进程启动后,它们又要为磁盘而竞争,CPU 却又在空转。。。。。。显然,通过结合 I/O 密集型和 CPU 密集型,能够使整个系统运行更流畅,效率更高。批处理系统通常有三个指标来衡量系统工作状态:吞吐量、周转时间和 CPU 利用率,吞吐量(throughout) 是系统每小时完成的作业数量。综合考虑,每小时完成 50 个工作要比每小时完成 40 个工作好。周转时间(Turnaround time) 是一种平均时间,它指的是从一个批处理提交开始直到作业完成时刻为止平均时间。该数据度量了用户要得到输出所需的平均等待时间。周转时间越小越好。CPU 利用率(CPU utilization) 通常作为批处理系统上的指标。即使如此, CPU 利用率也不是一个好的度量指标,真正有价值的衡量指标是系统每小时可以完成多少作业(吞吐量),以及完成作业需要多长时间(周转时间)。把 CPU 利用率作为度量指标,就像是引擎每小时转动了多少次来比较汽车的性能一样。而且知道 CPU 的利用率什么时候接近 100% 要比什么什么时候要求得到更多的计算能力要有用。交互式系统对于交互式系统,则有不同的指标。最重要的是尽量减少响应时间。这个时间说的是从执行指令开始到得到结果的时间。再有后台进程运行(例如,从网络上读取和保存 E-mail 文件)的个人计算机上,用户请求启动一个程序或打开一个文件应该优先于后台的工作。能够让所有的交互式请求首先运行的就是一个好的服务。一个相关的问题是 均衡性(proportionality),用户对做一件事情需要多长时间总是有一种固定(不过通常不正确)的看法。当认为一个请求很复杂需要较多时间时,用户会认为很正常并且可以接受,但是一个很简单的程序却花费了很长的运行时间,用户就会很恼怒。可以拿彩印和复印来举出一个简单的例子,彩印可能需要1分钟的时间,但是用户觉得复杂并且愿意等待一分钟,相反,复印很简单只需要 5 秒钟,但是复印机花费 1 分钟却没有完成复印操作,用户就会很焦躁。实时系统实时系统则有着和交互式系统不同的考量因素,因此也就有不同的调度目标。实时系统的特点是必须满足最后的截止时间。例如,如果计算机控制着以固定速率产生数据的设备,未能按时运行的话可能会导致数据丢失。因此,实时系统中最重要的需求是满足所有(或大多数)时间期限。在一些实事系统中,特别是涉及到多媒体的,可预测性很重要。偶尔不能满足最后的截止时间不重要,但是如果音频多媒体运行不稳定,声音质量会持续恶化。视频也会造成问题,但是耳朵要比眼睛敏感很多。为了避免这些问题,进程调度必须能够高度可预测的而且是有规律的。批处理中的调度现在让我们把目光从一般性的调度转换为特定的调度算法。下面我们会探讨在批处理中的调度。先来先服务很像是先到先得。。。可能最简单的非抢占式调度算法的设计就是 先来先服务(first-come,first-serverd)。使用此算法,将按照请求顺序为进程分配 CPU。最基本的,会有一个就绪进程的等待队列。当第一个任务从外部进入系统时,将会立即启动并允许运行任意长的时间。它不会因为运行时间太长而中断。当其他作业进入时,它们排到就绪队列尾部。当正在运行的进程阻塞,处于等待队列的第一个进程就开始运行。当一个阻塞的进程重新处于就绪态时,它会像一个新到达的任务,会排在队列的末尾,即排在所有进程最后。这个算法的强大之处在于易于理解和编程,在这个算法中,一个单链表记录了所有就绪进程。要选取一个进程运行,只要从该队列的头部移走一个进程即可;要添加一个新的作业或者阻塞一个进程,只要把这个作业或进程附加在队列的末尾即可。这是很简单的一种实现。不过,先来先服务也是有缺点的,那就是没有优先级的关系,试想一下,如果有 100 个 I/O 进程正在排队,第 101 个是一个 CPU 密集型进程,那岂不是需要等 100 个 I/O 进程运行完毕才会等到一个 CPU 密集型进程运行,这在实际情况下根本不可能,所以需要优先级或者抢占式进程的出现来优先选择重要的进程运行。最短作业优先批处理中,第二种调度算法是 最短作业优先(Shortest Job First),我们假设运行时间已知。例如,一家保险公司,因为每天要做类似的工作,所以人们可以相当精确地预测处理 1000 个索赔的一批作业需要多长时间。当输入队列中有若干个同等重要的作业被启动时,调度程序应使用最短优先作业算法如上图 a 所示,这里有 4 个作业 A、B、C、D ,运行时间分别为 8、4、4、4 分钟。若按图中的次序运行,则 A 的周转时间为 8 分钟,B 为 12 分钟,C 为 16 分钟,D 为 20 分钟,平均时间内为 14 分钟。现在考虑使用最短作业优先算法运行 4 个作业,如上图 b 所示,目前的周转时间分别为 4、8、12、20,平均为 11 分钟,可以证明最短作业优先是最优的。考虑有 4 个作业的情况,其运行时间分别为 a、b、c、d。第一个作业在时间 a 结束,第二个在时间 a + b 结束,以此类推。平均周转时间为 (4a + 3b + 2c + d) / 4 。显然 a 对平均值的影响最大,所以 a 应该是最短优先作业,其次是 b,然后是 c ,最后是 d 它就只能影响自己的周转时间了。需要注意的是,在所有的进程都可以运行的情况下,最短作业优先的算法才是最优的。最短剩余时间优先最短作业优先的抢占式版本被称作为 最短剩余时间优先(Shortest Remaining Time Next) 算法。使用这个算法,调度程序总是选择剩余运行时间最短的那个进程运行。当一个新作业到达时,其整个时间同当前进程的剩余时间做比较。如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。这种方式能够使短期作业获得良好的服务。交互式系统中的调度交互式系统中在个人计算机、服务器和其他系统中都是很常用的,所以有必要来探讨一下交互式调度轮询调度一种最古老、最简单、最公平并且最广泛使用的算法就是 轮询算法(round-robin)。每个进程都会被分配一个时间段,称为时间片(quantum),在这个时间片内允许进程运行。如果时间片结束时进程还在运行的话,则抢占一个 CPU 并将其分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 立即进行切换。轮询算法比较容易实现。调度程序所做的就是维护一个可运行进程的列表,就像下图中的 a,当一个进程用完时间片后就被移到队列的末尾,就像下图的 b。时间片轮询调度中唯一有意思的一点就是时间片的长度。从一个进程切换到另一个进程需要一定的时间进行管理处理,包括保存寄存器的值和内存映射、更新不同的表格和列表、清除和重新调入内存高速缓存等。这种切换称作 进程间切换(process switch) 和 上下文切换(context switch)。如果进程间的切换时间需要 1ms,其中包括内存映射、清除和重新调入高速缓存等,再假设时间片设为 4 ms,那么 CPU 在做完 4 ms 有用的工作之后,CPU 将花费 1 ms 来进行进程间的切换。因此,CPU 的时间片会浪费 20% 的时间在管理开销上。耗费巨大。为了提高 CPU 的效率,我们把时间片设置为 100 ms。现在时间的浪费只有 1%。但是考虑会发现下面的情况,如果在一个非常短的时间内到达 50 个请求,并且对 CPU 有不同的需求,此时会发生什么?50 个进程都被放在可运行进程列表中。如果 CP画U 是空闲的,第一个进程会立即开始执行,第二个直到 100 ms 以后才会启动,以此类推。不幸的是最后一个进程需要等待 5 秒才能获得执行机会。大部分用户都会觉得对于一个简短的指令运行 5 秒中是很慢的。如果队列末尾的某些请求只需要几号秒钟的运行时间的话,这种设计就非常糟糕了。另外一个因素是如果时间片设置长度要大于 CPU 使用长度,那么抢占就不会经常发生。相反,在时间片用完之前,大多数进程都已经阻塞了,那么就会引起进程间的切换。消除抢占可提高性能,因为进程切换仅在逻辑上必要时才发生,即流程阻塞且无法继续时才发生。结论可以表述如下:将上下文切换时间设置得太短会导致过多的进程切换并降低 CPU 效率,但设置时间太长会导致一个短请求很长时间得不到响应。最好的切换时间是在 20 - 50 毫秒之间设置。优先级调度轮询调度假设了所有的进程是同等重要的。但事实情况可能不是这样。例如,在一所大学中的等级制度,首先是院长,然后是教授、秘书、后勤人员,最后是学生。这种将外部情况考虑在内就实现了优先级调度(priority scheduling)它的基本思想很明确,每个进程都被赋予一个优先级,优先级高的进程优先运行。但是也不意味着高优先级的进程能够永远一直运行下去,调度程序会在每个时钟中断期间降低当前运行进程的优先级。如果此操作导致其优先级降低到下一个最高进程的优先级以下,则会发生进程切换。或者,可以为每个进程分配允许运行的最大时间间隔。当时间间隔用完后,下一个高优先级的进程会得到运行的机会。可以静态或者动态的为进程分配优先级。在一台军用计算机上,可以把将军所启动的进程设为优先级 100,上校为 90 ,少校为 80,上尉为 70,中尉为 60,以此类推。UNIX 中有一条命令为 nice ,它允许用户为了照顾他人而自愿降低自己进程的优先级,但是一般没人用。优先级也可以由系统动态分配,用于实现某种目的。例如,有些进程为 I/O 密集型,其多数时间用来等待 I/O 结束。当这样的进程需要 CPU 时,应立即分配 CPU,用来启动下一个 I/O 请求,这样就可以在另一个进程进行计算的同时执行 I/O 操作。这类 I/O 密集型进程长时间的等待 CPU 只会造成它长时间占用内存。使 I/O 密集型进程获得较好的服务的一种简单算法是,将其优先级设为 1/f,f 为该进程在上一时间片中所占的部分。一个在 50 ms 的时间片中只使用 1 ms 的进程将获得优先级 50 ,而在阻塞之前用掉 25 ms 的进程将具有优先级 2,而使用掉全部时间片的进程将得到优先级 1。可以很方便的将一组进程按优先级分成若干类,并且在各个类之间采用优先级调度,而在各类进程的内部采用轮转调度。下面展示了一个四个优先级类的系统它的调度算法主要描述如下:上面存在优先级为 4 类的可运行进程,首先会按照轮转法为每个进程运行一个时间片,此时不理会较低优先级的进程。若第 4 类进程为空,则按照轮询的方式运行第三类进程。若第 4 类和第 3 类进程都为空,则按照轮转法运行第 2 类进程。如果不对优先级进行调整,则低优先级的进程很容易产生饥饿现象。多级队列最早使用优先级调度的系统是 CTSS(Compatible TimeSharing System)。CTSS 是一种兼容分时系统,它有一个问题就是进程切换太慢,其原因是 IBM 7094 内存只能放进一个进程。IBM 是哥伦比亚大学计算机中心在 1964 - 1968 年的计算机CTSS 在每次切换前都需要将当前进程换出到磁盘,并从磁盘上读入一个新进程。CTSS 的设计者很快就认识到,为 CPU 密集型进程设置较长的时间片比频繁地分给他们很短的时间要更有效(减少交换次数)。另一方面,如前所述,长时间片的进程又会影响到响应时间,解决办法是设置优先级类。属于最高优先级的进程运行一个时间片,次高优先级进程运行 2 个时间片,再下面一级运行 4 个时间片,以此类推。当一个进程用完分配的时间片后,它被移到下一类最短进程优先对于批处理系统而言,由于最短作业优先常常伴随着最短响应时间,所以如果能够把它用于交互式进程,那将是非常好的。在某种程度上,的确可以做到这一点。交互式进程通常遵循下列模式:等待命令、执行命令、等待命令、执行命令。。。如果我们把每个命令的执行都看作一个分离的作业,那么我们可以通过首先运行最短的作业来使响应时间最短。这里唯一的问题是如何从当前可运行进程中找出最短的那一个进程。一种方式是根据进程过去的行为进行推测,并执行估计运行时间最短的那一个。假设每个终端上每条命令的预估运行时间为 T0,现在假设测量到其下一次运行时间为 T1,可以用两个值的加权来改进估计时间,即aT0+ (1- 1)T1。通过选择 a 的值,可以决定是尽快忘掉老的运行时间,还是在一段长时间内始终记住它们。当 a = 1/2 时,可以得到下面这个序列可以看到,在三轮过后,T0 在新的估计值中所占比重下降至 1/8。有时把这种通过当前测量值和先前估计值进行加权平均从而得到下一个估计值的技术称作 老化(aging)。这种方法会使用很多预测值基于当前值的情况。保证调度一种完全不同的调度方法是对用户做出明确的性能保证。一种实际而且容易实现的保证是:若用户工作时有 n 个用户登录,则每个用户将获得 CPU 处理能力的 1/n。类似地,在一个有 n 个进程运行的单用户系统中,若所有的进程都等价,则每个进程将获得 1/n 的 CPU 时间。彩票调度对用户进行承诺并在随后兑现承诺是一件好事,不过很难实现。但是存在着一种简单的方式,有一种既可以给出预测结果而又有一种比较简单的实现方式的算法,就是 彩票调度(lottery scheduling)算法。其基本思想是为进程提供各种系统资源(例如 CPU 时间)的彩票。当做出一个调度决策的时候,就随机抽出一张彩票,拥有彩票的进程将获得该资源。在应用到 CPU 调度时,系统可以每秒持有 50 次抽奖,每个中奖者将获得比如 20 毫秒的 CPU 时间作为奖励。George Orwell 关于 所有的进程是平等的,但是某些进程能够更平等一些。一些重要的进程可以给它们额外的彩票,以便增加他们赢得的机会。如果出售了 100 张彩票,而且有一个进程持有了它们中的 20 张,它就会有 20% 的机会去赢得彩票中奖。在长时间的运行中,它就会获得 20% 的CPU。相反,对于优先级调度程序,很难说明拥有优先级 40 究竟是什么意思,这里的规则很清楚,拥有彩票 f 份额的进程大约得到系统资源的 f 份额。如果希望进程之间协作的话可以交换它们之间的票据。例如,客户端进程给服务器进程发送了一条消息后阻塞,客户端进程可能会把自己所有的票据都交给服务器,来增加下一次服务器运行的机会。当服务完成后,它会把彩票还给客户端让其有机会再次运行。事实上,如果没有客户机,服务器也根本不需要彩票。可以把彩票理解为 buff,这个 buff 有 15% 的几率能让你产生 速度之靴 的效果。公平分享调度到目前为止,我们假设被调度的都是各个进程自身,而不用考虑该进程的拥有者是谁。结果是,如果用户 1 启动了 9 个进程,而用户 2 启动了一个进程,使用轮转或相同优先级调度算法,那么用户 1 将得到 90 % 的 CPU 时间,而用户 2 将之得到 10 % 的 CPU 时间。为了阻止这种情况的出现,一些系统在调度前会把进程的拥有者考虑在内。在这种模型下,每个用户都会分配一些CPU 时间,而调度程序会选择进程并强制执行。因此如果两个用户每个都会有 50% 的 CPU 时间片保证,那么无论一个用户有多少个进程,都将获得相同的 CPU 份额。实时系统中的调度实时系统(real-time) 是一个时间扮演了重要作用的系统。典型的,一种或多种外部物理设备发给计算机一个服务请求,而计算机必须在一个确定的时间范围内恰当的做出反应。例如,在 CD 播放器中的计算机会获得从驱动器过来的位流,然后必须在非常短的时间内将位流转换为音乐播放出来。如果计算时间过长,那么音乐就会听起来有异常。再比如说医院特别护理部门的病人监护装置、飞机中的自动驾驶系统、列车中的烟雾警告装置等,在这些例子中,正确但是却缓慢的响应要比没有响应甚至还糟糕。实时系统可以分为两类,硬实时(hard real time) 和 软实时(soft real time) 系统,前者意味着必须要满足绝对的截止时间;后者的含义是虽然不希望偶尔错失截止时间,但是可以容忍。在这两种情形中,实时都是通过把程序划分为一组进程而实现的,其中每个进程的行为是可预测和提前可知的。这些进程一般寿命较短,并且极快的运行完成。在检测到一个外部信号时,调度程序的任务就是按照满足所有截止时间的要求调度进程。实时系统中的事件可以按照响应方式进一步分类为周期性(以规则的时间间隔发生)事件或 非周期性(发生时间不可预知)事件。一个系统可能要响应多个周期性事件流,根据每个事件处理所需的时间,可能甚至无法处理所有事件。例如,如果有 m 个周期事件,事件 i 以周期 Pi 发生,并需要 Ci 秒 CPU 时间处理一个事件,那么可以处理负载的条件是只有满足这个条件的实时系统称为可调度的,这意味着它实际上能够被实现。一个不满足此检验标准的进程不能被调度,因为这些进程共同需要的 CPU 时间总和大于 CPU 能提供的时间。举一个例子,考虑一个有三个周期性事件的软实时系统,其周期分别是 100 ms、200 m 和 500 ms。如果这些事件分别需要 50 ms、30 ms 和 100 ms 的 CPU 时间,那么该系统时可调度的,因为 0.5 + 0.15 + 0.2 < 1。如果此时有第四个事件加入,其周期为 1 秒,那么此时这个事件如果不超过 150 ms,那么仍然是可以调度的。忽略上下文切换的时间。实时系统的调度算法可以是静态的或动态的。前者在系统开始运行之前做出调度决策;后者在运行过程中进行调度决策。只有在可以提前掌握所完成的工作以及必须满足的截止时间等信息时,静态调度才能工作,而动态调度不需要这些限制。调度策略和机制到目前为止,我们隐含的假设系统中所有进程属于不同的分组用户并且进程间存在相互竞争 CPU 的情况。通常情况下确实如此,但有时也会发生一个进程会有很多子进程并在其控制下运行的情况。例如,一个数据库管理系统进程会有很多子进程。每一个子进程可能处理不同的请求,或者每个子进程实现不同的功能(如请求分析、磁盘访问等)。主进程完全可能掌握哪一个子进程最重要(或最紧迫),而哪一个最不重要。但是,以上讨论的调度算法中没有一个算法从用户进程接收有关的调度决策信息,这就导致了调度程序很少能够做出最优的选择。解决问题的办法是将 调度机制(scheduling mechanism) 和 调度策略(scheduling policy) 分开,这是长期一贯的原则。这也就意味着调度算法在某种方式下被参数化了,但是参数可以被用户进程填写。让我们首先考虑数据库的例子。假设内核使用优先级调度算法,并提供了一条可供进程设置优先级的系统调用。这样,尽管父进程本身并不参与调度,但它可以控制如何调度子进程的细节。调度机制位于内核,而调度策略由用户进程决定,调度策略和机制分离是一种关键性思路。线程调度当若干进程都有多个线程时,就存在两个层次的并行:进程和线程。在这样的系统中调度处理有本质的差别,这取决于所支持的是用户级线程还是内核级线程(或两者都支持)。首先考虑用户级线程,由于内核并不知道有线程存在,所以内核还是和以前一样地操作,选取一个进程,假设为 A,并给予 A 以时间片控制。A 中的线程调度程序决定哪个线程运行。假设为 A1。由于多道线程并不存在时钟中断,所以这个线程可以按其意愿任意运行多长时间。如果该线程用完了进程的全部时间片,内核就会选择另一个进程继续运行。在进程 A 终于又一次运行时,线程 A1 会接着运行。该线程会继续耗费 A 进程的所有时间,直到它完成工作。不过,线程运行不会影响到其他进程。其他进程会得到调度程序所分配的合适份额,不会考虑进程 A 内部发生的事情。现在考虑 A 线程每次 CPU 计算的工作比较少的情况,例如:在 50 ms 的时间片中有 5 ms 的计算工作。于是,每个线程运行一会儿,然后把 CPU 交回给线程调度程序。这样在内核切换到进程 B 之前,就会有序列 A1,A2,A3,A1,A2,A3,A1,A2,A3,A1 。 如下所示运行时系统使用的调度算法可以是上面介绍算法的任意一种。从实用方面考虑,轮转调度和优先级调度更为常用。唯一的局限是,缺乏一个时钟中断运行过长的线程。但由于线程之间的合作关系,这通常也不是问题。现在考虑使用内核线程的情况,内核选择一个特定的线程运行。它不用考虑线程属于哪个进程,不过如果有必要的话,也可以这么做。对被选择的线程赋予一个时间片,而且如果超过了时间片,就会强制挂起该线程。一个线程在 50 ms 的时间片内,5 ms 之后被阻塞,在 30 ms 的时间片中,线程的顺序会是 A1,B1,A2,B2,A3,B3。如下图所示用户级线程和内核级线程之间的主要差别在于性能。用户级线程的切换需要少量的机器指令(想象一下Java程序的线程切换),而内核线程需要完整的上下文切换,修改内存映像,使高速缓存失效,这会导致了若干数量级的延迟。另一方面,在使用内核级线程时,一旦线程阻塞在 I/O 上就不需要在用户级线程中那样将整个进程挂起。从进程 A 的一个线程切换到进程 B 的一个线程,其消耗要远高于运行进程 A 的两个线程(涉及修改内存映像,修改高速缓存),内核对这种切换的消耗是了解到,可以通过这些信息作出决定。 </div>
文章
缓存  ·  算法  ·  自动驾驶  ·  Java  ·  编译器  ·  程序员  ·  调度  ·  数据库  ·  C语言  ·  Windows
2022-08-13
写给大忙人看的进程和线程(三)
POSIX 线程为了使编写可移植线程程序成为可能,IEEE 在 IEEE 标准 1003.1c 中定义了线程标准。线程包被定义为 Pthreads。大部分的 UNIX 系统支持它。这个标准定义了 60 多种功能调用,一一列举不太现实,下面为你列举了一些常用的系统调用。POSIX线程(通常称为pthreads)是一种独立于语言而存在的执行模型,以及并行执行模型。它允许程序控制时间上重叠的多个不同的工作流程。每个工作流程都称为一个线程,可以通过调用POSIX Threads API来实现对这些流程的创建和控制。可以把它理解为线程的标准。POSIX Threads 的实现在许多类似且符合POSIX的操作系统上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在现有 Windows API 之上实现了pthread。IEEE 是世界上最大的技术专业组织,致力于为人类的利益而发展技术。线程调用描述pthread_create创建一个新线程pthread_exit结束调用的线程pthread_join等待一个特定的线程退出pthread_yield释放 CPU 来运行另外一个线程pthread_attr_init创建并初始化一个线程的属性结构pthread_attr_destory删除一个线程的属性结构所有的 Pthreads 都有特定的属性,每一个都含有标识符、一组寄存器(包括程序计数器)和一组存储在结构中的属性。这个属性包括堆栈大小、调度参数以及其他线程需要的项目。新的线程会通过 pthread_create 创建,新创建的线程的标识符会作为函数值返回。这个调用非常像是 UNIX 中的 fork 系统调用(除了参数之外),其中线程标识符起着 PID 的作用,这么做的目的是为了和其他线程进行区分。当线程完成指派给他的工作后,会通过 pthread_exit 来终止。这个调用会停止线程并释放堆栈。一般一个线程在继续运行前需要等待另一个线程完成它的工作并退出。可以通过 pthread_join 线程调用来等待别的特定线程的终止。而要等待线程的线程标识符作为一个参数给出。有时会出现这种情况:一个线程逻辑上没有阻塞,但感觉上它已经运行了足够长的时间并且希望给另外一个线程机会去运行。这时候可以通过 pthread_yield 来完成。下面两个线程调用是处理属性的。pthread_attr_init 建立关联一个线程的属性结构并初始化成默认值,这些值(例如优先级)可以通过修改属性结构的值来改变。最后,pthread_attr_destroy 删除一个线程的结构,释放它占用的内存。它不会影响调用它的线程,这些线程会一直存在。为了更好的理解 pthread 是如何工作的,考虑下面这个例子#include <pthread.h> #include <stdio.h> #include <stdlib.h> #define NUMBER_OF_THREADS 10 void *print_hello_world(vvoid *tid){ /* 输出线程的标识符,然后退出 */ printf("Hello World. Greetings from thread %d\n",tid); pthread_exit(NULL); } int main(int argc,char *argv[]){ /* 主程序创建 10 个线程,然后退出 */ pthread_t threads[NUMBER_OF_THREADS]; int status,i; for(int i = 0;i < NUMBER_OF_THREADS;i++){ printf("Main here. Creating thread %d\n",i); status = pthread_create(&threads[i], NULL, print_hello_world, (void *)i); if(status != 0){ printf("Oops. pthread_create returned error code %d\n",status); exit(-1); } } exit(NULL); }主线程在宣布它的指责之后,循环 NUMBER_OF_THREADS 次,每次创建一个新的线程。如果线程创建失败,会打印出一条信息后退出。在创建完成所有的工作后,主程序退出。线程实现主要有三种实现方式在用户空间中实现线程;在内核空间中实现线程;在用户和内核空间中混合实现线程。下面我们分开讨论一下在用户空间中实现线程第一种方法是把整个线程包放在用户空间中,内核对线程一无所知,它不知道线程的存在。所有的这类实现都有同样的通用结构线程在运行时系统之上运行,运行时系统是管理线程过程的集合,包括前面提到的四个过程:pthread_create, pthread_exit, pthread_join 和 pthread_yield。运行时系统(Runtime System) 也叫做运行时环境,该运行时系统提供了程序在其中运行的环境。此环境可能会解决许多问题,包括应用程序内存的布局,程序如何访问变量,在过程之间传递参数的机制,与操作系统的接口等等。编译器根据特定的运行时系统进行假设以生成正确的代码。通常,运行时系统将负责设置和管理堆栈,并且会包含诸如垃圾收集,线程或语言内置的其他动态的功能。在用户空间管理线程时,每个进程需要有其专用的线程表(thread table),用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态。该线程表由运行时系统统一管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程的所有信息,与内核在进程表中存放的信息完全一样。在用户空间实现线程的优势在用户空间中实现线程要比在内核空间中实现线程具有这些方面的优势:考虑如果在线程完成时或者是在调用 pthread_yield 时,必要时会进程线程切换,然后线程的信息会被保存在运行时环境所提供的线程表中,然后,线程调度程序来选择另外一个需要运行的线程。保存线程的状态和调度程序都是本地过程,所以启动他们比进行内核调用效率更高。因而不需要切换到内核,也就不需要上下文切换,也不需要对内存高速缓存进行刷新,因为线程调度非常便捷,因此效率比较高。在用户空间实现线程还有一个优势就是它允许每个进程有自己定制的调度算法。例如在某些应用程序中,那些具有垃圾收集线程的应用程序(知道是谁了吧)就不用担心自己线程会不会在不合适的时候停止,这是一个优势。用户线程还具有较好的可扩展性,因为内核空间中的内核线程需要一些表空间和堆栈空间,如果内核线程数量比较大,容易造成问题。在用户空间实现线程的劣势尽管在用户空间实现线程会具有一定的性能优势,但是劣势还是很明显的,你如何实现阻塞系统调用呢?假设在还没有任何键盘输入之前,一个线程读取键盘,让线程进行系统调用是不可能的,因为这会停止所有的线程。所以,使用线程的一个目标是能够让线程进行阻塞调用,并且要避免被阻塞的线程影响其他线程。与阻塞调用类似的问题是缺页中断问题,实际上,计算机并不会把所有的程序都一次性的放入内存中,如果某个程序发生函数调用或者跳转指令到了一条不在内存的指令上,就会发生页面故障,而操作系统将到磁盘上取回这个丢失的指令,这就称为缺页故障。而在对所需的指令进行读入和执行时,相关的进程就会被阻塞。如果只有一个线程引起页面故障,内核由于甚至不知道有线程存在,通常会把整个进程阻塞直到磁盘 I/O 完成为止,尽管其他的线程是可以运行的。另外一个问题是,如果一个线程开始运行,该线程所在进程中的其他线程都不能运行,除非第一个线程自愿的放弃 CPU,在一个单进程内部,没有时钟中断,所以不可能使用轮转调度的方式调度线程。除非其他线程能够以自己的意愿进入运行时环境,否则调度程序没有可以调度线程的机会。在内核中实现线程现在我们考虑使用内核来实现线程的情况,此时不再需要运行时环境了。另外,每个进程中也没有线程表。相反,在内核中会有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时,它会进行一个系统调用,这个系统调用通过对线程表的更新来完成线程创建或销毁工作。内核中的线程表持有每个线程的寄存器、状态和其他信息。这些信息和用户空间中的线程信息相同,但是位置却被放在了内核中而不是用户空间中。另外,内核还维护了一张进程表用来跟踪系统状态。所有能够阻塞的调用都会通过系统调用的方式来实现,当一个线程阻塞时,内核可以进行选择,是运行在同一个进程中的另一个线程(如果有就绪线程的话)还是运行一个另一个进程中的线程。但是在用户实现中,运行时系统始终运行自己的线程,直到内核剥夺它的 CPU 时间片(或者没有可运行的线程存在了)为止。由于在内核中创建或者销毁线程的开销比较大,所以某些系统会采用可循环利用的方式来回收线程。当某个线程被销毁时,就把它标志为不可运行的状态,但是其内部结构没有受到影响。稍后,在必须创建一个新线程时,就会重新启用旧线程,把它标志为可用状态。如果某个进程中的线程造成缺页故障后,内核很容易的就能检查出来是否有其他可运行的线程,如果有的话,在等待所需要的页面从磁盘读入时,就选择一个可运行的线程运行。这样做的缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止)比较多,就会带来很大的开销。混合实现结合用户空间和内核空间的优点,设计人员采用了一种内核级线程的方式,然后将用户级线程与某些或者全部内核线程多路复用起来在这种模型中,编程人员可以自由控制用户线程和内核线程的数量,具有很大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。进程间通信进程是需要频繁的和其他进程进行交流的。例如,在一个 shell 管道中,第一个进程的输出必须传递给第二个进程,这样沿着管道进行下去。因此,进程之间如果需要通信的话,必须要使用一种良好的数据结构以至于不能被中断。下面我们会一起讨论有关 进程间通信(Inter Process Communication, IPC) 的问题。关于进程间的通信,这里有三个问题上面提到了第一个问题,那就是一个进程如何传递消息给其他进程。第二个问题是如何确保两个或多个线程之间不会相互干扰。例如,两个航空公司都试图为不同的顾客抢购飞机上的最后一个座位。第三个问题是数据的先后顺序的问题,如果进程 A 产生数据并且进程 B 打印数据。则进程 B 打印数据之前需要先等 A 产生数据后才能够进行打印。需要注意的是,这三个问题中的后面两个问题同样也适用于线程第一个问题在线程间比较好解决,因为它们共享一个地址空间,它们具有相同的运行时环境,可以想象你在用高级语言编写多线程代码的过程中,线程通信问题是不是比较容易解决?另外两个问题也同样适用于线程,同样的问题可用同样的方法来解决。我们后面会慢慢讨论这三个问题,你现在脑子中大致有个印象即可。竞态条件在一些操作系统中,协作的进程可能共享一些彼此都能读写的公共资源。公共资源可能在内存中也可能在一个共享文件。为了讲清楚进程间是如何通信的,这里我们举一个例子:一个后台打印程序。当一个进程需要打印某个文件时,它会将文件名放在一个特殊的后台目录(spooler directory)中。另一个进程 打印后台进程(printer daemon) 会定期的检查是否需要文件被打印,如果有的话,就打印并将该文件名从目录下删除。假设我们的后台目录有非常多的 槽位(slot),编号依次为 0,1,2,…,每个槽位存放一个文件名。同时假设有两个共享变量:out,指向下一个需要打印的文件;in,指向目录中下个空闲的槽位。可以把这两个文件保存在一个所有进程都能访问的文件中,该文件的长度为两个字。在某一时刻,0 至 3 号槽位空,4 号至 6 号槽位被占用。在同一时刻,进程 A 和 进程 B 都决定将一个文件排队打印,情况如下墨菲法则(Murphy) 中说过,任何可能出错的地方终将出错,这句话生效时,可能发生如下情况。进程 A 读到 in 的值为 7,将 7 存在一个局部变量 next_free_slot 中。此时发生一次时钟中断,CPU 认为进程 A 已经运行了足够长的时间,决定切换到进程 B 。进程 B 也读取 in 的值,发现是 7,然后进程 B 将 7 写入到自己的局部变量 next_free_slot 中,在这一时刻两个进程都认为下一个可用槽位是 7 。进程 B 现在继续运行,它会将打印文件名写入到 slot 7 中,然后把 in 的指针更改为 8 ,然后进程 B 离开去做其他的事情现在进程 A 开始恢复运行,由于进程 A 通过检查 next_free_slot也发现 slot 7 的槽位是空的,于是将打印文件名存入 slot 7 中,然后把 in 的值更新为 8 ,由于 slot 7 这个槽位中已经有进程 B 写入的值,所以进程 A 的打印文件名会把进程 B 的文件覆盖,由于打印机内部是无法发现是哪个进程更新的,它的功能比较局限,所以这时候进程 B 永远无法打印输出,类似这种情况,即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition)。调试竞态条件是一种非常困难的工作,因为绝大多数情况下程序运行良好,但在极少数的情况下会发生一些无法解释的奇怪现象。临界区不仅共享资源会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?或许一句话可以概括说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说,我们需要一种 互斥(mutual exclusion) 条件,这也就是说,如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)。上面问题的纠结点在于,在进程 A 对共享变量的使用未结束之前进程 B 就使用它。在任何操作系统中,为了实现互斥操作而选用适当的原语是一个主要的设计问题,接下来我们会着重探讨一下。避免竞争问题的条件可以用一种抽象的方式去描述。大部分时间,进程都会忙于内部计算和其他不会导致竞争条件的计算。然而,有时候进程会访问共享内存或文件,或者做一些能够导致竞态条件的操作。我们把对共享内存进行访问的程序片段称作 临界区域(critical region) 或 临界区(critical section)。如果我们能够正确的操作,使两个不同进程不可能同时处于临界区,就能避免竞争条件,这也是从操作系统设计角度来进行的。尽管上面这种设计避免了竞争条件,但是不能确保并发线程同时访问共享数据的正确性和高效性。一个好的解决方案,应该包含下面四种条件任何时候两个进程不能同时处于临界区不应对 CPU 的速度和数量做任何假设位于临界区外的进程不得阻塞其他进程不能使任何进程无限等待进入临界区从抽象的角度来看,我们通常希望进程的行为如上图所示,在 t1 时刻,进程 A 进入临界区,在 t2 的时刻,进程 B 尝试进入临界区,因为此时进程 A 正在处于临界区中,所以进程 B 会阻塞直到 t3 时刻进程 A 离开临界区,此时进程 B 能够允许进入临界区。最后,在 t4 时刻,进程 B 离开临界区,系统恢复到没有进程的原始状态。忙等互斥下面我们会继续探讨实现互斥的各种设计,在这些方案中,当一个进程正忙于更新其关键区域的共享内存时,没有其他进程会进入其关键区域,也不会造成影响。 </div>
文章
算法  ·  Unix  ·  Java  ·  编译器  ·  Shell  ·  Linux  ·  API  ·  调度  ·  Android开发  ·  Windows
2022-08-13
写给大忙人看的进程和线程(一)
我们平常说的进程和线程更多的是基于编程语言的角度来说的,那么你真的了解什么是线程和进程吗?那么我们就从操作系统的角度来了解一下什么是进程和线程。进程操作系统中最核心的概念就是 进程,进程是对正在运行中的程序的一个抽象。操作系统的其他所有内容都是围绕着进程展开的。进程是操作系统提供的最古老也是最重要的概念之一。即使可以使用的 CPU 只有一个,它们也支持(伪)并发操作。它们会将一个单独的 CPU 抽象为多个虚拟机的 CPU。可以说:没有进程的抽象,现代操作系统将不复存在。所有现代的计算机会在同一时刻做很多事情,过去使用计算机的人(单 CPU)可能完全无法理解现在这种变化,举个例子更能说明这一点:首先考虑一个 Web 服务器,请求都来自于 Web 网页。当一个请求到达时,服务器会检查当前页是否在缓存中,如果是在缓存中,就直接把缓存中的内容返回。如果缓存中没有的话,那么请求就会交给磁盘来处理。但是,从 CPU 的角度来看,磁盘请求需要更长的时间,因为磁盘请求会很慢。当硬盘请求完成时,更多其他请求才会进入。如果有多个磁盘的话,可以在第一个请求完成前就可以连续的对其他磁盘发出部分或全部请求。很显然,这是一种并发现象,需要有并发控制条件来控制并发现象。现在考虑只有一个用户的 PC。当系统启动时,许多进程也在后台启动,用户通常不知道这些进程的启动,试想一下,当你自己的计算机启动的时候,你能知道哪些进程是需要启动的么?这些后台进程可能是一个需要输入电子邮件的电子邮件进程,或者是一个计算机病毒查杀进程来周期性的更新病毒库。某个用户进程可能会在所有用户上网的时候打印文件以及刻录 CD-ROM,这些活动都需要管理。于是一个支持多进程的多道程序系统就会显得很有必要了。在许多多道程序系统中,CPU 会在进程间快速切换,使每个程序运行几十或者几百毫秒。然而,严格意义来说,在某一个瞬间,CPU 只能运行一个进程,然而我们如果把时间定位为 1 秒内的话,它可能运行多个进程。这样就会让我们产生并行的错觉。有时候人们说的 伪并行(pseudoparallelism) 就是这种情况,以此来区分多处理器系统(该系统由两个或多个 CPU 来共享同一个物理内存)再来详细解释一下伪并行:伪并行是指单核或多核处理器同时执行多个进程,从而使程序更快。通过以非常有限的时间间隔在程序之间快速切换CPU,因此会产生并行感。缺点是 CPU 时间可能分配给下一个进程,也可能不分配给下一个进程。因为 CPU 执行速度很快,进程间的换进换出也非常迅速,因此我们很难对多个并行进程进行跟踪,所以,在经过多年的努力后,操作系统的设计者开发了用于描述并行的一种概念模型(顺序进程),使得并行更加容易理解和分析,对该模型的探讨,也是本篇文章的主题。下面我们就来探讨一下进程模型进程模型在进程模型中,所有计算机上运行的软件,通常也包括操作系统,被组织为若干顺序进程(sequential processes),简称为 进程(process) 。一个进程就是一个正在执行的程序的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来说,每个进程都有各自的虚拟 CPU,但是实际情况是 CPU 会在各个进程之间进行来回切换。如上图所示,这是一个具有 4 个程序的多道处理程序,在进程不断切换的过程中,程序计数器也在不同的变化。在上图中,这 4 道程序被抽象为 4 个拥有各自控制流程(即每个自己的程序计数器)的进程,并且每个程序都独立的运行。当然,实际上只有一个物理程序计数器,每个程序要运行时,其逻辑程序计数器会装载到物理程序计数器中。当程序运行结束后,其物理程序计数器就会是真正的程序计数器,然后再把它放回进程的逻辑计数器中。从下图我们可以看到,在观察足够长的一段时间后,所有的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正运行。因此,当我们说一个 CPU 只能真正一次运行一个进程的时候,即使有 2 个核(或 CPU),每一个核也只能一次运行一个线程。由于 CPU 会在各个进程之间来回快速切换,所以每个进程在 CPU 中的运行时间是无法确定的。并且当同一个进程再次在 CPU 中运行时,其在 CPU 内部的运行时间往往也是不固定的。进程和程序之间的区别是非常微妙的,但是通过一个例子可以让你加以区分:想想一位会做饭的计算机科学家正在为他的女儿制作生日蛋糕。他有做生日蛋糕的食谱,厨房里有所需的原谅:面粉、鸡蛋、糖、香草汁等。在这个比喻中,做蛋糕的食谱就是程序、计算机科学家就是 CPU、而做蛋糕的各种原料都是输入数据。进程就是科学家阅读食谱、取来各种原料以及烘焙蛋糕等一系列动作的总和。现在假设科学家的儿子跑过来告诉他,说他的头被蜜蜂蜇了一下,那么此时科学家会记录出来他做蛋糕这个过程到了哪一步,然后拿出急救手册,按照上面的步骤给他儿子实施救助。这里,会涉及到进程之间的切换,科学家(CPU)会从做蛋糕(进程)切换到实施医疗救助(另一个进程)。等待伤口处理完毕后,科学家会回到刚刚记录做蛋糕的那一步,继续制作。这里的关键思想是认识到一个进程所需的条件,进程是某一类特定活动的总和,它有程序、输入输出以及状态。单个处理器可以被若干进程共享,它使用某种调度算法决定何时停止一个进程的工作,并转而为另外一个进程提供服务。另外需要注意的是,如果一个进程运行了两遍,则被认为是两个进程。那么我们了解到进程模型后,那么进程是如何创建的呢?进程的创建操作系统需要一些方式来创建进程。下面是一些创建进程的方式系统初始化(init)正在运行的程序执行了创建进程的系统调用(比如 fork)用户请求创建一个新进程初始化一个批处理工作系统初始化启动操作系统时,通常会创建若干个进程。其中有些是前台进程(numerous processes),也就是同用户进行交互并替他们完成工作的进程。一些运行在后台,并不与特定的用户进行交互,例如,设计一个进程来接收发来的电子邮件,这个进程大部分的时间都在休眠,但是只要邮件到来后这个进程就会被唤醒。还可以设计一个进程来接收对该计算机上网页的传入请求,在请求到达的进程唤醒来处理网页的传入请求。进程运行在后台用来处理一些活动像是 e-mail,web 网页,新闻,打印等等被称为 守护进程(daemons)。大型系统会有很多守护进程。在 UNIX 中,ps 程序可以列出正在运行的进程, 在 Windows 中,可以使用任务管理器。系统调用创建除了在启动阶段创建进程之外,一些新的进程也可以在后面创建。通常,一个正在运行的进程会发出系统调用用来创建一个或多个新进程来帮助其完成工作。例如,如果有大量的数据需要经过网络调取并进行顺序处理,那么创建一个进程读数据,并把数据放到共享缓冲区中,而让第二个进程取走并正确处理会比较容易些。在多处理器中,让每个进程运行在不同的 CPU 上也可以使工作做的更快。用户请求创建在许多交互式系统中,输入一个命令或者双击图标就可以启动程序,以上任意一种操作都可以选择开启一个新的进程,在基本的 UNIX 系统中运行 X,新进程将接管启动它的窗口。在 Windows 中启动进程时,它一般没有窗口,但是它可以创建一个或多个窗口。每个窗口都可以运行进程。通过鼠标或者命令就可以切换窗口并与进程进行交互。交互式系统是以人与计算机之间大量交互为特征的计算机系统,比如游戏、web浏览器,IDE 等集成开发环境。批处理创建最后一种创建进程的情形会在大型机的批处理系统中应用。用户在这种系统中提交批处理作业。当操作系统决定它有资源来运行另一个任务时,它将创建一个新进程并从其中的输入队列中运行下一个作业。从技术上讲,在所有这些情况下,让现有流程执行流程是通过创建系统调用来创建新流程的。该进程可能是正在运行的用户进程,是从键盘或鼠标调用的系统进程或批处理程序。这些就是系统调用创建新进程的过程。该系统调用告诉操作系统创建一个新进程,并直接或间接指示在其中运行哪个程序。在 UNIX 中,仅有一个系统调用来创建一个新的进程,这个系统调用就是 fork。这个调用会创建一个与调用进程相关的副本。在 fork 后,一个父进程和子进程会有相同的内存映像,相同的环境字符串和相同的打开文件。通常,子进程会执行 execve 或者一个简单的系统调用来改变内存映像并运行一个新的程序。例如,当一个用户在 shell 中输出 sort 命令时,shell 会 fork 一个子进程然后子进程去执行 sort 命令。这两步过程的原因是允许子进程在 fork 之后但在 execve 之前操作其文件描述符,以完成标准输入,标准输出和标准错误的重定向。在 Windows 中,情况正相反,一个简单的 Win32 功能调用 CreateProcess,会处理流程创建并将正确的程序加载到新的进程中。这个调用会有 10 个参数,包括了需要执行的程序、输入给程序的命令行参数、各种安全属性、有关打开的文件是否继承控制位、优先级信息、进程所需要创建的窗口规格以及指向一个结构的指针,在该结构中新创建进程的信息被返回给调用者。除了 CreateProcess Win 32 中大概有 100 个其他的函数用于处理进程的管理,同步以及相关的事务。下面是 UNIX 操作系统和 Windows 操作系统系统调用的对比在 UNIX 和 Windows 中,进程创建之后,父进程和子进程有各自不同的地址空间。如果其中某个进程在其地址空间中修改了一个词,这个修改将对另一个进程不可见。在 UNIX 中,子进程的地址空间是父进程的一个拷贝,但是却是两个不同的地址空间;不可写的内存区域是共享的。某些 UNIX 实现是正是在两者之间共享,因为它不能被修改。或者,子进程共享父进程的所有内存,但是这种情况下内存通过 写时复制(copy-on-write) 共享,这意味着一旦两者之一想要修改部分内存,则这块内存首先被明确的复制,以确保修改发生在私有内存区域。再次强调,可写的内存是不能被共享的。但是,对于一个新创建的进程来说,确实有可能共享创建者的资源,比如可以共享打开的文件。在 Windows 中,从一开始父进程的地址空间和子进程的地址空间就是不同的。进程的终止进程在创建之后,它就开始运行并做完成任务。然而,没有什么事儿是永不停歇的,包括进程也一样。进程早晚会发生终止,但是通常是由于以下情况触发的正常退出(自愿的)错误退出(自愿的)严重错误(非自愿的)被其他进程杀死(非自愿的)正常退出多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。这个调用在 UNIX 中是 exit ,在 Windows 中是 ExitProcess。面向屏幕中的软件也支持自愿终止操作。字处理软件、Internet 浏览器和类似的程序中总有一个供用户点击的图标或菜单项,用来通知进程删除它所打开的任何临时文件,然后终止。错误退出进程发生终止的第二个原因是发现严重错误,例如,如果用户执行如下命令cc foo.c为了能够编译 foo.c 但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行重试,所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出。严重错误进程终止的第三个原因是由进程引起的错误,通常是由于程序中的错误所导致的。例如,执行了一条非法指令,引用不存在的内存,或者除数是 0 等。在有些系统比如 UNIX 中,进程可以通知操作系统,它希望自行处理某种类型的错误,在这类错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。被其他进程杀死第四个终止进程的原因是,某个进程执行系统调用告诉操作系统杀死某个进程。在 UNIX 中,这个系统调用是 kill。在 Win32 中对应的函数是 TerminateProcess(注意不是系统调用)。进程的层次结构在一些系统中,当一个进程创建了其他进程后,父进程和子进程就会以某种方式进行关联。子进程它自己就会创建更多进程,从而形成一个进程层次结构。UNIX 进程体系在 UNIX 中,进程和它的所有子进程以及子进程的子进程共同组成一个进程组。当用户从键盘中发出一个信号后,该信号被发送给当前与键盘相关的进程组中的所有成员(它们通常是在当前窗口创建的所有活动进程)。每个进程可以分别捕获该信号、忽略该信号或采取默认的动作,即被信号 kill 掉。这里有另一个例子,可以用来说明层次的作用,考虑 UNIX 在启动时如何初始化自己。一个称为 init 的特殊进程出现在启动映像中 。当 init 进程开始运行时,它会读取一个文件,文件会告诉它有多少个终端。然后为每个终端创建一个新进程。这些进程等待用户登录。如果登录成功,该登录进程就执行一个 shell 来等待接收用户输入指令,这些命令可能会启动更多的进程,以此类推。因此,整个操作系统中所有的进程都隶属于一个单个以 init 为根的进程树。Windows 进程体系相反,Windows 中没有进程层次的概念,Windows 中所有进程都是平等的,唯一类似于层次结构的是在创建进程的时候,父进程得到一个特别的令牌(称为句柄),该句柄可以用来控制子进程。然而,这个令牌可能也会移交给别的操作系统,这样就不存在层次结构了。而在 UNIX 中,进程不能剥夺其子进程的 进程权。(这样看来,还是 Windows 比较渣)。 </div>
文章
缓存  ·  算法  ·  安全  ·  Unix  ·  Java  ·  编译器  ·  Shell  ·  调度  ·  开发工具  ·  Windows
2022-08-13
写给大忙人看的操作系统(三)
操作系统博物馆操作系统已经存在了大半个世纪,在这段时期内,出现了各种类型的操作系统,但并不是所有的操作系统都很出名,下面就罗列一些比较出名的操作系统大型机操作系统高端一些的操作系统是大型机操作系统,这些大型操作系统可在大型公司的数据中心找到。这些计算机的 I/O 容量与个人计算机不同。一个大型计算机有 1000 个磁盘和数百万 G 字节的容量是很正常,如果有这样一台个人计算机朋友会很羡慕。大型机也在高端 Web 服务器、大型电子商务服务站点上。服务器操作系统下一个层次是服务器操作系统。它们运行在服务器上,服务器可以是大型个人计算机、工作站甚至是大型机。它们通过网络为若干用户服务,并且允许用户共享硬件和软件资源。服务器可提供打印服务、文件服务或 Web 服务。Internet 服务商运行着许多台服务器机器,为用户提供支持,使 Web 站点保存 Web 页面并处理进来的请求。典型的服务器操作系统有 Solaris、FreeBSD、Linux 和 Windows Server 201x多处理器操作系统获得大型计算能力的一种越来越普遍的方式是将多个 CPU 连接到一个系统中。依据它们连接方式和共享方式的不同,这些系统称为并行计算机,多计算机或多处理器。他们需要专门的操作系统,不过通常采用的操作系统是配有通信、连接和一致性等专门功能的服务器操作系统的变体。个人计算机中近来出现了多核芯片,所以常规的台式机和笔记本电脑操作系统也开始与小规模多处理器打交道,而核的数量正在与时俱进。许多主流操作系统比如 Windows 和 Linux 都可以运行在多核处理器上。个人计算机系统接下来一类是个人计算机操作系统。现代个人计算机操作系统支持多道处理程序。在启动时,通常有几十个程序开始运行,它们的功能是为单个用户提供良好的支持。这类系统广泛用于字处理、电子表格、游戏和 Internet 访问。常见的例子是 Linux、FreeBSD、Windows 7、Windows 8 和苹果公司的 OS X 。掌上计算机操作系统随着硬件越来越小化,我们看到了平板电脑、智能手机和其他掌上计算机系统。掌上计算机或者 PDA(Personal Digital Assistant),个人数字助理 是一种可以握在手中操作的小型计算机。这部分市场已经被谷歌的 Android 系统和苹果的 IOS主导。嵌入式操作系统嵌入式操作系统用来控制设备的计算机中运行,这种设备不是一般意义上的计算机,并且不允许用户安装软件。典型的例子有微波炉、汽车、DVD 刻录机、移动电话以及 MP3 播放器一类的设备。所有的软件都运行在 ROM 中,这意味着应用程序之间不存在保护,从而获得某种简化。主要的嵌入式系统有 Linux、QNX 和 VxWorks传感器节点操作系统有许多用途需要配置微小传感器节点网络。这些节点是一种可以彼此通信并且使用无线通信基站的微型计算机。这类传感器网络可以用于建筑物周边保护、国土边界保卫、森林火灾探测、气象预测用的温度和降水测量等。每个传感器节点是一个配有 CPU、RAM、ROM 以及一个或多个环境传感器的实实在在的计算机。节点上运行一个小型但是真是的操作系统,通常这个操作系统是事件驱动的,可以响应外部事件。实时操作系统另一类操作系统是实时操作系统,这些系统的特征是将时间作为关键参数。例如,在工业过程控制系统中,工厂中的实时计算机必须收集生产过程的数据并用有关数据控制机器。如果某个动作必须要在规定的时刻发生,这就是硬实时系统。可以在工业控制、民用航空、军事以及类似应用中看到很多这样的系统。另一类系统是 软实时系统,在这种系统中,虽然不希望偶尔违反最终时限,但仍可以接受,并不会引起任何永久性损害。数字音频或多媒体系统就是这类系统。智能手机也是软实时系统。智能卡操作系统最小的操作系统运行在智能卡上。智能卡是一种包含一块 CPU 芯片的信用卡。它有非常严格的运行能耗和存储空间的限制。有些卡具有单项功能,如电子支付;有些智能卡是面向 Java 的。这意味着在智能卡的 ROM 中有一个 Java 虚拟机(Java Virtual Machine, JVM)解释器。操作系统概念大部分操作系统提供了特定的基础概念和抽象,例如进程、地址空间、文件等,它们是需要理解的核心内容。下面我们会简要介绍一些基本概念,为了说明这些概念,我们会不时的从 UNIX 中提出示例,相同的示例也会存在于其他系统中,我们后面会进行介绍。进程操作系统一个很关键的概念就是 进程(Process)。进程的本质就是操作系统执行的一个程序。与每个进程相关的是地址空间(address space),这是从某个最小值的存储位置(通常是零)到某个最大值的存储位置的列表。在这个地址空间中,进程可以进行读写操作。地址空间中存放有可执行程序,程序所需要的数据和它的栈。与每个进程相关的还有资源集,通常包括寄存器(registers)(寄存器一般包括程序计数器(program counter)和堆栈指针(stack pointer))、打开文件的清单、突发的报警、有关的进程清单和其他需要执行程序的信息。你可以把进程看作是容纳运行一个程序所有信息的一个容器。对进程建立一种直观感觉的方式是考虑建立一种多程序的系统。考虑下面这种情况:用户启动一个视频编辑程序,指示它按照某种格式转换视频,然后再去浏览网页。同时,一个检查电子邮件的后台进程被唤醒并开始运行,这样,我们目前就会有三个活动进程:视频编辑器、Web 浏览器和电子邮件接收程序。操作系统周期性的挂起一个进程然后启动运行另一个进程,这可能是由于过去一两秒钟程序用完了 CPU 分配的时间片,而 CPU 转而运行另外的程序。像这样暂时中断进程后,下次应用程序在此启动时,必须要恢复到与中断时刻相同的状态,这在我们用户看起来是习以为常的事情,但是操作系统内部却做了巨大的事情。这就像和足球比赛一样,一场完美精彩的比赛是可以忽略裁判的存在的。这也意味着在挂起时该进程的所有信息都要被保存下来。例如,进程可能打开了多个文件进行读取。与每个文件相关联的是提供当前位置的指针(即下一个需要读取的字节或记录的编号)。当进程被挂起时,必须要保存这些指针,以便在重新启动进程后执行的 read调用将能够正确的读取数据。在许多操作系统中,与一个进程有关的所有信息,除了该进程自身地址空间的内容以外,均存放在操作系统的一张表中,称为 进程表(process table),进程表是数组或者链表结构,当前存在每个进程都要占据其中的一项。所以,一个挂起的进程包括:进程的地址空间(往往称作磁芯映像, core image,纪念过去的磁芯存储器),以及对应的进程表项(其中包括寄存器以及稍后启动该进程所需要的许多其他信息)。与进程管理有关的最关键的系统调用往往是决定着进程的创建和终止的系统调用。考虑一个典型的例子,有一个称为 命令解释器(command interpreter) 或 shell 的进程从终端上读取命令。此时,用户刚键入一条命令要求编译一个程序。shell 必须先创建一个新进程来执行编译程序,当编译程序结束时,它执行一个系统调用来终止自己的进程。如果一个进程能够创建一个或多个进程(称为子进程),而且这些进程又可以创建子进程,则很容易找到进程数,如下所示上图表示一个进程树的示意图,进程 A 创建了两个子进程 B 和进程 C,子进程 B 又创建了三个子进程 D、E、F。合作完成某些作业的相关进程经常需要彼此通信来完成作业,这种通信称为进程间通信(interprocess communication)。我们在后面会探讨进程间通信。其他可用的进程系统调用包括:申请更多的内存(或释放不再需要的内存),等待一个子进程结束,用另一个程序覆盖该程序。有时,需要向一个正在运行的进程传递信息,而该进程并没有等待接收信息。例如,一个进程通过网络向另一台机器上的进程发送消息进行通信。为了保证一条消息或消息的应答不丢失。发送者要求它所在的操作系统在指定的若干秒后发送一个通知,这样如果对方尚未收到确认消息就可以进行重新发送。在设定该定时器后,程序可以继续做其他工作。在限定的时间到达后,操作系统会向进程发送一个 警告信号(alarm signal)。这个信号引起该进程暂时挂起,无论该进程正在做什么,系统将其寄存器的值保存到堆栈中,并开始重新启动一个特殊的信号处理程,比如重新发送可能丢失的消息。这些信号是软件模拟的硬件中断,除了定时器到期之外,该信号可以通过各种原因产生。许多由硬件检测出来的陷阱,如执行了非法指令或使用了无效地址等,也被转换成该信号并交给这个进程。系统管理器授权每个进程使用一个给定的 UID(User IDentification)。每个启动的进程都会有一个操作系统赋予的 UID,子进程拥有与父进程一样的 UID。用户可以是某个组的成员,每个组也有一个 GID(Group IDentification)。在 UNIX 操作系统中,有一个 UID 是 超级用户(superuser),或者 Windows 中的管理员(administrator),它具有特殊的权利,可以违背一些保护规则。在大型系统中,只有系统管理员掌握着那些用户可以称为超级用户。地址空间每台计算机都有一些主存用来保存正在执行的程序。在一个非常简单的操作系统中,仅仅有一个应用程序运行在内存中。为了运行第二个应用程序,需要把第一个应用程序移除才能把第二个程序装入内存。复杂一些的操作系统会允许多个应用程序同时装入内存中运行。为了防止应用程序之间相互干扰(包括操作系统),需要有某种保护机制。虽然此机制是在硬件中实现,但却是由操作系统控制的。上述观点涉及对计算机主存的管理和保护。另一种同等重要并与存储器有关的内容是管理进程的地址空间。通常,每个进程有一些可以使用的地址集合,典型值从 0 开始直到某个最大值。一个进程可拥有的最大地址空间小于主存。在这种情况下,即使进程用完其地址空间,内存也会有足够的内存运行该进程。但是,在许多 32 位或 64 位地址的计算机中,分别有 2^32 或 2^64 字节的地址空间。如果一个进程有比计算机拥有的主存还大的地址空间,而且该进程希望使用全部的内存,那该怎么处理?在早期的计算机中是无法处理的。但是现在有了一种虚拟内存的技术,正如前面讲到过的,操作系统可以把部分地址空间装入主存,部分留在磁盘上,并且在需要时来回交换它们。文件几乎所有操作系统都支持的另一个关键概念就是文件系统。如前所述,操作系统的一项主要功能是屏蔽磁盘和其他 I/O 设备的细节特性,给程序员提供一个良好、清晰的独立于设备的抽象文件模型。创建文件、删除文件、读文件和写文件 都需要系统调用。在文件可以读取之前,必须先在磁盘上定位和打开文件,在文件读过之后应该关闭该文件,有关的系统调用则用于完成这类操作。为了提供保存文件的地方,大多数个人计算机操作系统都有目录(directory) 的概念,从而可以把文件分组。比如,学生可以给每个课程都创建一个目录,用于保存该学科的资源,另一个目录可以存放电子邮件,再有一个目录可以存放万维网主页。这就需要系统调用创建和删除目录、将已有文件放入目录中,从目录中删除文件等。目录项可以是文件或者目录,目录和目录之间也可以嵌套,这样就产生了文件系统进程和文件层次都是以树状的结构组织,但这两种树状结构有不少不同之处。一般进程的树状结构层次不深(很少超过三层),而文件系统的树状结构要深一些,通常会到四层甚至五层。进程树层次结构是暂时的,通常最多存在几分钟,而目录层次则可能存在很长时间。进程和文件在权限保护方面也是有区别的。一般来说,父进程能控制和访问子进程,而在文件和目录中通常存在一种机制,使文件所有者之外的其他用户也能访问该文件。目录层结构中的每一个文件都可以通过从目录的顶部即 根目录(Root directory) 开始的路径名(path name) 来确定。绝对路径名包含了从根目录到该文件的所有目录清单,它们之间用斜杠分隔符分开,在上面的大学院系文件系统中,文件 CS101 的路径名是 /Faculty/Prof.Brown/Courses/CS101。最开始的斜杠分隔符代表的是根目录 /,也就是文件系统的绝对路径。“出于历史原因,Windows 下面的文件系统以 \ 来作为分隔符,但是 Linux 会以 / 作为分隔符。在上面的系统中,每个进程会有一个 工作目录(working directory),对于没有以斜线开头给出绝对地址的路径,将在这个工作目录下寻找。如果 /Faculty/Prof.Brown 是工作目录,那么 /Courses/CS101 与上面给定的绝对路径名表示的是同一个文件。进程可以通过使用系统调用指定新的工作目录,从而变更其工作目录。在读写文件之前,首先需要打开文件,检查其访问权限。若权限许可,系统将返回一个小整数,称作文件描述符(file descriptor),供后续操作使用。若禁止访问,系统则返回一个错误码。在 UNIX 中,另一个重要的概念是 特殊文件(special file)。提供特殊文件是为了使 I/O 设备看起来像文件一般。这样,就像使用系统调用读写文件一样,I/O 设备也可以通过同样的系统调用进行读写。特殊文件有两种,一种是块儿特殊文件(block special file) 和 字符特殊文件(character special file)。块特殊文件指那些由可随机存取的块组成的设备,如磁盘等。比如打开一个块特殊文件,然后读取第4块,程序可以直接访问设备的第4块而不必考虑存放在该文件的文件系统结构。类似的,字符特殊文件用于打印机、调制解调起和其他接受或输出字符流的设备。按照惯例,特殊文件保存在 /dev 目录中。例如,/devv/lp 是打印机。还有一种与进程和文件相关的特性是管道,管道(pipe) 是一种虚文件,他可以连接两个进程如果 A 和 B 希望通过管道对话,他们必须提前设置管道。当进程 A 相对进程 B 发送数据时,它把数据写到管道上,相当于管道就是输出文件。这样,在 UNIX 中两个进程之间的通信就非常类似于普通文件的读写了。保护计算机中含有大量的信息,用户希望能够对这些信息中有用而且重要的信息加以保护,这些信息包括电子邮件、商业计划等,管理这些信息的安全性完全依靠操作系统来保证。例如,文件提供授权用户访问。比如 UNIX 操作系统,UNIX 操作系统通过对每个文件赋予一个 9 位二进制保护代码,对 UNIX 中的文件实现保护。该保护代码有三个位子段,一个用于所有者,一个用于与所有者同组(用户被系统管理员划分成组)的其他成员,一个用于其他人。每个字段中有一位用于读访问,一位用于写访问,一位用于执行访问。这些位就是著名的 rwx位。例如,保护代码 rwxr-x--x 的含义是所有者可以读、写或执行该文件,其他的组成员可以读或执行(但不能写)此文件、而其他人可以执行(但不能读和写)该文件。shell操作系统是执行系统调用的代码。编辑器、编译器、汇编程序、链接程序、使用程序以及命令解释符等,尽管非常重要,非常有用,但是它们确实不是操作系统的组成部分。下面我们着重介绍一下 UNIX 下的命令提示符,也就是 shell,shell 虽然有用,但它也不是操作系统的一部分,然而它却能很好的说明操作系统很多特性,下面我们就来探讨一下。shell 有许多种,例如 sh、csh、ksh 以及 bash等,它们都支持下面这些功能,最早起的 shell 可以追溯到 sh用户登录时,会同时启动一个 shell,它以终端作为标准输入和标准输出。首先显示提示符(prompt),它可能是一个美元符号($),提示用户 shell 正在等待接收命令,假如用户输入dateshell 会创建一个子进程,并运行 date 做为子进程。在该子进程运行期间,shell 将等待它结束。在子进程完成时,shell 会显示提示符并等待下一行输入。用户可以将标准输出重定向到一个文件中,例如date > file同样的,也可以将标准输入作为重定向sort <file1> file2这会调用 sort 程序来接收 file1 的内容并把结果输出到 file2。可以将一个应用程序的输出通过管道作为另一个程序的输入,因此有cat file1 file2 file3 | sort > /dev/lp这会调用 cat 应用程序来合并三个文件,将其结果输送到 sort 程序中并按照字典进行排序。sort 应用程序又被重定向到 /dev/lp ,显然这是一个打印操作。 </div>
文章
传感器  ·  存储  ·  Unix  ·  Java  ·  Shell  ·  Linux  ·  程序员  ·  编译器  ·  芯片  ·  Windows
2022-08-13
C++运算符重载(五)之关系运算符重载
关系运算符重载作用:重载关系运算符,可以让两个自定义类型对象进行对比操作示例:class Person{public:    Person(string name, int age)    {        this->m_Name = name;        this->m_Age = age;    };    bool operator==(Person & p)    {        if (this->m_Name == p.m_Name && this->m_Age == p.m_Age)        {            return true;        }        else        {            return false;        }    }    bool operator!=(Person & p)    {        if (this->m_Name == p.m_Name && this->m_Age == p.m_Age)        {            return false;        }        else        {            return true;        }    }    string m_Name;    int m_Age;};void test01(){    //int a = 0;    //int b = 0;    Person a("孙悟空", 18);    Person b("孙悟空", 18);    if (a == b)    {        cout << "a和b相等" << endl;    }    else    {        cout << "a和b不相等" << endl;    }    if (a != b)    {        cout << "a和b不相等" << endl;    }    else    {        cout << "a和b相等" << endl;    }}int main() {    test01();    system("pause");    return 0;}实例一、定义一个日期类用于测试class Date{public:    Date(int year = 1900, int month = 1, int day = 1):_year(year),_month(month),_day(day)    {}    void print()//输出日期    {        cout << _year << "/" << _month << "/" << _day << endl;    }private:    int _year;    int _month;    int _day;};正常情况下如果想比较两个日期大小是无法实现的,这是因为运算符默认都是给内置类型用的。int main(){    Date d1(2022, 2, 21);    Date d2(2022, 2, 23);    Date d3(2022, 2, 24);    //d1 == d2;直接比较会导致无法编译    return 0;}二、重载运算符==函数名:operator加上运算符参数:有几个操作数就有几个参数,参数类型就是要操作对象的类型返回值:看运算符运算后的返回值是什么//存在this指针,要少传一个参数    bool operator==(const Date& x)//引用节省空间,const保护实参    {        return _year == x._year && _month == x._month && _day == x._day;    }公有函数无法访问私有变量,所以运算符重载要写在类内当作成员函数c三、日期赋值=参数类型返回值检测是否自己给自己赋值返回 * this一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。如果不写,会默认生成赋值重载,和拷贝构造行为类似,内置类型会完成值拷贝,自定义类型成员会调用他的赋值重载 //赋值重载    Date operator=(const Date& d)//返回值类型是Date    {        _year = d._year;        _month = d._month;        _day = d._day;        return *this;//支持连续赋值,this是当前对象的别名,拷贝构造。    }测试结果int main(){    Date d1(2022, 2, 21);    Date d2(2022, 2, 23);    Date d3(2022, 2, 24);    d1 == d2;    //d1.operator== (d2);//可以调用但可读性差    //d1 == d2;//编译器自动转换为 d1.operator== (d2);         d1 = d3;    d1.print();    //赋值运算符重载:用于两个已经定义出的对象间的拷贝赋值        //拷贝构造:一个对象准备定义时,用另一个对象来初始化他    Date d4(d3);    d4.print();    Date d5 = d3;//这里是拷贝构造,只要是创建时定义就是拷贝构造,注意区分赋值重载。    d1 = d3 = d2;//连续赋值,链式编程思想    d1.print();    return 0;}
文章
编译器  ·  C++
2022-08-13
理解双亲委派模式
3、初始化初始化阶段是执行类构造器() 方法的过程。类构造器()方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生,代码从上往下执行。当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化虚拟机会保证一个类的() 方法在多线程环境中被正确加锁和同步总结就是:初始化是为类的静态变量赋予正确的初始值类加载器的介绍启动(Bootstrap)类加载器扩展(Extension)类加载器系统类加载器自定义加载器根类加载器(bootstrap class loader)它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。扩展类加载器(extensions class loader)扩展类加载器是指Sun公司(已被Oracle收购)实现的sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。系统类加载器(system class loader)被称为系统(也称为应用)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。(Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它一种任务委派模式,)类加载器加载Class大致要经过如下8个步骤:检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。从文件中载入Class,成功后跳至第8步。抛出ClassNotFountException异常。返回对应的java.lang.Class对象理解双亲委派模式双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。
文章
前端开发  ·  Oracle  ·  Java  ·  关系型数据库  ·  编译器  ·  开发者  ·  C++
2022-08-13
JVM运行时数据区
2 为什么说java是跨平台语言这个夸平台是中间语言(JVM)实现的夸平台java有JVM从软件层面屏蔽了底层硬件、指令层面的细节让他兼容各种系统难道 C 和 C++ 不能夸平台吗 其实也可以C和C++需要在编译器层面去兼容不同操作系统的不同层面,写过C和C++的就知道不同操作系统的有些代码是不一样3 Jdk和Jre和JVM的区别看Java官方的图片,Jdk中包括了Jre,Jre中包括了JVMJvm在倒数第二层 由他可以在(最后一层的)各种平台上运行Jre大部分都是 C 和 C++ 语言编写的,他是我们在编译java时所需要的基础的类库Jdk还包括了一些Jre之外的东西 ,就是这些东西帮我们编译Java代码的, 还有就是监控Jvm的一些工具这就好像一个人一样,我一般情况吃什么从来不用考虑进入了身体那一个部位,可是总有一天,假如吃了不该吃的也是要进医院的深入学习JVM注释:JVM就是Java虚拟机,Java虚拟机就是JVM1 JVM运行时数据区什么是运行时数据区(就是我们java运行时的东西是放在那里的)2 解析JVM运行时数据区2.1 方法区(Method Area)方法区是所有线程共享的内存区域,它用于存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它有个别命叫Non-Heap(非堆)。当方法区无法满足内存分配需求时,抛出OutOfMemoryError异常。2.2 Java堆(Java Heap)java堆是java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。java堆是垃圾收集器管理的主要区域,因此也被成为“GC堆”。从内存回收角度来看java堆可分为:新生代和老生代。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。无论怎么划分,都与存放内容无关,无论哪个区域,存储的都是对象实例,进一步的划分都是为了更好的回收内存,或者更快的分配内存。根据Java虚拟机规范的规定,java堆可以处于物理上不连续的内存空间中。当前主流的虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制)。如果堆中没有内存可以完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。2.3 程序计数器(Program Counter Register)程序计数器是一块较小的内存空间,它可以看作是:保存当前线程所正在执行的字节码指令的地址(行号)由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。称之为“线程私有”的内存。程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。总结:也可以把它叫做线程计数器例子:在java中最小的执行单位是线程,线程是要执行指令的,执行的指令最终操作的就是我们的电脑,就是 CPU。在CPU上面去运行,有个非常不稳定的因素,叫做调度策略,这个调度策略是时基于时间片的,也就是当前的这一纳秒是分配给那个指令的。
文章
存储  ·  监控  ·  Java  ·  编译器  ·  调度  ·  C++
2022-08-13
进程调度算法有哪些
进程调度算法有哪些?先来先服务非抢占式的调度算法,按照请求的顺序进行调度。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。另外,对I/O密集型进程也不利,因为这种进程每次进行I/O操作之后又得重新排队。短作业优先非抢占式的调度算法,按估计运行时间最短的顺序进行调度。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。最短剩余时间优先最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。时间片轮转将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。时间片轮转算法的效率和时间片的大小有很大关系:因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。而如果时间片过长,那么实时性就不能得到保证。优先级调度为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。延伸问题:什么是抢占式调度?什么是非抢占式调度?抢占式就是说操作系统将正在运行的进程强行暂停,由调度器将CPU分配给其他就绪进程。非抢占式是调度器一旦把处理机分配给某进程后便让它一直运行下去,直到进程完成或发生进程调度进程调度某事件而阻塞时,才把处理机分配给另一个进程。什么是上下文切换?对于单核单线程CPU而言,在某一时刻只能执行一条CPU指令。上下文切换(Context Switch)是一种将CPU资源从一个进程分配给另一个进程的机制。从用户角度看,计算机能够并行运行多个进程,这恰恰是操作系统通过快速上下文切换造成的结果。在切换的过程中,操作系统需要先存储当前进程的状态(包括内存空间的指针,当前执行完的指令等等),再读入下一个进程的状态,然后执行此进程。系统调用和库函数有什么区别?系统调用是应用程序向系统内核请求服务的方式。可以包括硬件相关的服务(例如,访问硬盘等),或者创建新进程,调度其他进程等。系统调用是程序和操作系统之间的重要接口。库函数就是说把一些常用的函数编写完放到一个文件里,编写应用程序时调用,这是由第三方提供的,发生在用户地址空间。在移植性方面,不同操作系统的系统调用一般是不同的,移植性差;库函数会相对好一些。比如说在所有的ANSI C编译器版本中,C库函数是相同的。在调用开销方面,系统调用需要在用户空间和内核环境间切换,开销较大;而库函数调用开销较小。
文章
存储  ·  算法  ·  编译器  ·  调度
2022-08-13
并发和并行有什么区别
并发和并行有什么区别?并发就是在一段时间内,多个任务都会被处理;但在某一时刻,只有一个任务在执行。单核处理器可以做到并发。比如有两个进程A和B,A运行一个时间片之后,切换到B,B运行一个时间片之后又切换到A。因为切换速度足够快,所以宏观上表现为在一段时间内能同时运行多个程序。并行就是在同一时刻,有多个任务在执行。这个需要多核处理器才能完成,在微观上就能同时执行多条指令,不同的程序被放到不同的处理器上运行,这个是物理上的多个进程同时进行。大内核和微内核有什么区别?大内核,就是将操作系统的全部功能都放进内核里面,包括调度、文件系统、网络、设备驱动器、存储管理等等,组成一个紧密连接整体。大内核的优点就是效率高,但是很难定位bug,拓展性比较差,每次需要增加新的功能,都要将新的代码和原来的内核代码重新编译。微内核与单体内核不同,微内核只是将操作中最核心的功能加入内核,包括IPC、地址空间分配和基本的调度,这些东西都在内核态运行,其他功能作为模块被内核调用,并且是在用户空间运行。微内核比较好维护和拓展,但是效率可能不高,因为需要频繁地在内核态和用户态之间切换。分时系统和实时系统有什么区别?分时系统(Sharing time system)就是系统把CPU时间分成很短的时间片,轮流地分配给多个作业。它的优点就是对多个用户的多个作业都能保证足够快的响应时间,并且有效提高了资源的利用率。实时系统(Real-time system)是系统对外部输入的信息,能够在规定的时间内(截止期限)处理完毕并做出反应。它的优点是能够集中地及时地处理并作出反应,高可靠性,安全性。通常计算机采用的是分时,就是多个进程/用户之间共享CPU,从形势上实现多任务。各个用户/进程之间的调度并非精准度特别高,如果一个进程被锁住,可以给它分配更多的时间。而实时操作系统则不同,软件和硬件必须遵从严格的时间限制,超过时限的进程可能直接被终止。在这样的操作系统中,每次加锁都需要仔细考虑。静态链接和动态链接有什么区别?静态链接就是在编译期间,由编译器和连接器将静态库集成到应用程序内,并制作成目标文件以及可以独立运作的可执行文件。静态库一般是一些外部函数与变量的集合。静态库很方便,但是如果我们只是想用库中的某一个函数,却仍然得把所有的内容都链接进去。一个更现代的方法是使用共享库,避免了在文件中静态库的大量重复。动态链接可以在首次载入的时候执行,也可以在程序开始执行的时候完成。这个是由动态链接器完成,比方标准 C 库(libc.so) 通常就是动态链接的,这样所有的程序可以共享同一个库,而不用分别进行封装。编译有哪些阶段?预处理阶段:处理以 # 开头的预处理命令;编译阶段:翻译成汇编文件;汇编阶段:将汇编文件翻译成可重定位目标文件;链接阶段:将可重定位目标文件和 printf.o 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件。进程有哪些状态?在五状态模型里面,进程一共有5中状态,分别是创建、就绪、运行、终止、阻塞。运行状态就是进程正在CPU上运行。在单处理机环境下,每一时刻最多只有一个进程处于运行状态。就绪状态就是说进程已处于准备运行的状态,即进程获得了除CPU之外的一切所需资源,一旦得到CPU即可运行。阻塞状态就是进程正在等待某一事件而暂停运行,比如等待某资源为可用或等待I/O完成。即使CPU空闲,该进程也不能运行。运行态→阻塞态:往往是由于等待外设,等待主存等资源分配或等待人工干预而引起的。阻塞态→就绪态:则是等待的条件已满足,只需分配到处理器后就能运行。运行态→就绪态:不是由于自身原因,而是由外界原因使运行状态的进程让出处理器,这时候就变成就绪态。例如时间片用完,或有更高优先级的进程来抢占处理器等。就绪态→运行态:系统按某种策略选中就绪队列中的一个进程占用处理器,此时就变成了运行态。
文章
存储  ·  编译器  ·  调度
2022-08-13
详谈Java关键字static
1. 什么是static关键字 在Java中static表示“全局”或者“静态”的意思,用来修饰成员变量和成员方法,当然也可以修饰代码块。 Java把内存分为栈内存和堆内存,其中栈内存用来存放一些基本类型的变量、数组和对象的引用,堆内存主要存放一些对象。在JVM加载一个类的时候,若该类存在static修饰的成员变量和成员方法,则会为这些成员变量和成员方法在固定的位置开辟一个固定大小的内存区域(只要这个类被加载,Java虚拟机就能根据类名在运行时数据区的方法区内定找到他们),有了这些“固定”的特性,那么JVM就可以非常方便地访问他们。 同时被static修饰的成员变量和成员方法是独立于该类的,它不依赖于某个特定的实例变量,也就是说它被该类的所有实例共享。所有实例的引用都指向同一个地方,任何一个实例对其的修改都会导致其他实例的变化。public class User {    private static int userNumber  = 0 ;        public User(){        userNumber ++;    }        public static void main(String[] args) {        User user1 = new User();        User user2 = new User();                System.out.println("user1 userNumber:" + User.userNumber);        System.out.println("user2 userNumber:" + User.userNumber);    }}  输出:user1 userNumber:2user2 userNumber:22. static关键字的用途2.1 static变量 static变量也称作静态变量,静态变量和非静态变量的区别是:静态变量被所有的对象所共享,在内存中只有一个副本,它当且仅当在类初次加载时会被初始化。而非静态变量是对象所拥有的,在创建对象的时候被初始化,存在多个副本,各个对象拥有的副本互不影响。 static成员变量的初始化顺序按照定义的顺序进行初始化。 所以我们一般在这两种情况下使用静态变量:对象之间共享数据、访问方便。public class TestStatic {        public static int count = 0;        public static void main(String[] args){        TestStatic test1=new TestStatic();        System.out.println(test1.count);        TestStatic test2=new TestStatic();        test2.count++;        System.out.println(test1.count+" "+test2.count+" "+TestStatic.count);    }}输出结果:01 1 1 可见,static变量并不是所在类的某个具体对象所有,而是该类的所有对象所共有的,静态变量既能被对象调用,也能直接拿类来调用。2.2 static方法 static方法一般称作静态方法,由于静态方法不依赖于任何对象就可以进行访问,因此对于静态方法来说,是没有this的,因为它不依附于任何对象,既然都没有对象,就谈不上this了。并且由于这个特性,在静态方法中不能访问类的非静态成员变量和非静态成员方法,因为非静态成员方法/变量都是必须依赖具体的对象才能够被调用。 但是要注意的是,虽然在静态方法中不能访问非静态成员方法和非静态成员变量,但是在非静态成员方法中是可以访问静态成员方法/变量的。 因为static方法独立于任何实例,因此static方法必须被实现,而不能是抽象的abstract。总结一下,对于静态方法需要注意以下几点:(1)它们仅能调用其他的static 方法。(2)它们只能访问static数据。(3)它们不能以任何方式引用this 或super。举个简单的例子:  在上面的代码中,由于print2方法是独立于对象存在的,可以直接用过类名调用。假如说可以在静态方法中访问非静态方法/变量的话,那么如果在main方法中有下面一条语句:  MyObject.print2();  此时对象都没有,str2根本就不存在,所以就会产生矛盾了。同样对于方法也是一样,由于你无法预知在print1方法中是否访问了非静态成员变量,所以也禁止在静态成员方法中访问非静态成员方法。  而对于非静态成员方法,它访问静态成员方法/变量显然是毫无限制的。  因此,如果说想在不创建对象的情况下调用某个方法,就可以将这个方法设置为static。我们最常见的static方法就是main方法,至于为什么main方法必须是static的,现在就很清楚了。因为程序在执行main方法的时候没有创建任何对象,因此只有通过类名来访问。2.3 static代码块  static关键字还有一个比较关键的作用就是 用来形成静态代码块以优化程序性能。static块可以置于类中的任何地方,类中可以有多个static块。在类初次被加载的时候,会按照static块的顺序来执行每个static块,并且只会执行一次。  为什么说static块可以用来优化程序性能,是因为它的特性:只会在类加载的时候执行一次。下面看个例子:class Person{     private Date birthDate;           public Person(Date birthDate) {        this.birthDate = birthDate;    }           boolean isBornBoomer() {        Date startDate = Date.valueOf("1946");        Date endDate = Date.valueOf("1964");        return birthDate.compareTo(startDate)>=0 && birthDate.compareTo(endDate) < 0;    } }   isBornBoomer是用来这个人是否是1946-1964年出生的,而每次isBornBoomer被调用的时候,都会生成startDate和birthDate两个对象,造成了空间浪费,如果改成这样效率会更好:class Person{     private Date birthDate;    private static Date startDate,endDate;    static{        startDate = Date.valueOf("1946");        endDate = Date.valueOf("1964");    }           public Person(Date birthDate) {        this.birthDate = birthDate;    }           boolean isBornBoomer() {        return birthDate.compareTo(startDate)>=0 && birthDate.compareTo(endDate) < 0;    } }   因此,很多时候会将一些只需要进行一次的初始化操作都放在static代码块中进行。3. static关键字的误区3.1.static关键字会改变类中成员的访问权限吗?  有些初学的朋友会将java中的static与C/C++中的static关键字的功能混淆了。在这里只需要记住一点:与C/C++中的static不同,Java中的static关键字不会影响到变量或者方法的作用域。在Java中能够影响到访问权限的只有private、public、protected(包括包访问权限)这几个关键字。看下面的例子就明白了: 提示错误"age是私有的 ",这说明static关键字并不会改变变量和方法的访问权限。3.2 能通过this访问静态成员变量吗?  虽然对于静态方法来说没有this,那么在非静态方法中能够通过this访问静态成员变量吗?先看下面的一个例子,这段代码输出的结果是什么?public class Main {       static int value = 33;        public static void main(String[] args) throws Exception{        new Main().printValue();    }       private void printValue(){        int value = 3;        System.out.println(this.value);    } } 输出结果:33  这里面主要考察队this和static的理解。this代表什么?this代表当前对象,那么通过new Main()来调用printValue的话,当前对象就是通过new Main()生成的对象。而static变量是被对象所享有的,因此在printValue中的this.value的值毫无疑问是33。在printValue方法内部的value是局部变量,根本不可能与this关联,所以输出结果是33。在这里永远要记住一点:静态成员变量虽然独立于对象,但是不代表不可以通过对象去访问,所有的静态方法和静态变量都可以通过对象访问(只要访问权限足够)。3.3 static能作用于局部变量么?  在C/C++中static是可以作用域局部变量的,但是在Java中切记:static是不允许用来修饰局部变量。不要问为什么,这是Java语法的规定。3.4 static和final一块用表示什么? static final用来修饰成员变量和成员方法,可简单理解为“全局常量”! 对于变量,表示一旦给值就不可修改,并且通过类名可以访问。 对于方法,表示不可覆盖,并且可以通过类名直接访问。4. 常见的笔试面试题4.1 第一题public class Test extends Base{       static{        System.out.println("test static");    }           public Test(){        System.out.println("test constructor");    }           public static void main(String[] args) {        new Test();    } }   class Base{           static{        System.out.println("base static");    }           public Base(){        System.out.println("base constructor");    } } 输出结果:base statictest staticbase constructortest constructor  这段代码具体的执行过程,在执行开始,先要寻找到main方法,因为main方法是程序的入口,但是在执行main方法之前,必须先加载Test类,而在加载Test类的时候发现Test类继承自Base类,因此会转去先加载Base类,在加载Base类的时候,发现有static块,便执行了static块。在Base类加载完成之后,便继续加载Test类,然后发现Test类中也有static块,便执行static块。在加载完所需的类之后,便开始执行main方法。在main方法中执行new Test()的时候会先调用父类的构造器,然后再调用自身的构造器。因此,便出现了上面的输出结果。4.2 第二题public class Test {     Person person = new Person("Test");     static{         System.out.println("test static");     }           public Test() {         System.out.println("test constructor");     }           public static void main(String[] args) {         new MyClass();     } }   class Person{     static{         System.out.println("person static");     }     public Person(String str) {         System.out.println("person "+str);     } }     class MyClass extends Test {     Person person = new Person("MyClass");     static{         System.out.println("myclass static");     }           public MyClass() {         System.out.println("myclass constructor");     } } 输出结果:test staticmyclass staticperson staticperson Testtest constructorperson MyClassmyclass constructor  这段代码的具体执行过程:首先加载Test类,因此会执行Test类中的static块。接着执行new MyClass(),而MyClass类还没有被加载,因此需要加载MyClass类。在加载MyClass类的时候,发现MyClass类继承自Test类,但是由于Test类已经被加载了,所以只需要加载MyClass类,那么就会执行MyClass类的中的static块。在加载完之后,就通过构造器来生成对象。而在生成对象的时候,必须先初始化父类的成员变量,因此会执行Test中的Person person = new Person(),而Person类还没有被加载过,因此会先加载Person类并执行Person类中的static块,接着执行父类的构造器,完成了父类的初始化,然后就来初始化自身了,因此会接着执行MyClass中的Person person = new Person(),最后执行MyClass的构造器。4.3 第三题public class Test {         static{        System.out.println("test static 1");    }    public static void main(String[] args) {             }         static{        System.out.println("test static 2");    }}输出结果:test static 1test static 2  虽然在main方法中没有任何语句,但是还是会输出,原因上面已经讲述过了。另外,static块可以出现类中的任何地方(只要不是方法内部,记住,任何方法内部都不行),并且执行是按照static块的顺序执行的。5. 静态代码块的初始化顺序class Parent {    static String name = "hello";    {        System.out.println("parent block");    }    static {        System.out.println("parent static block");    }    public Parent() {        System.out.println("parent constructor");    }}class Child extends Parent {    static String childName = "hello";    {        System.out.println("child block");    }    static {        System.out.println("child static block");    }    public Child() {        System.out.println("child constructor");    }}public class TestStatic {    public static void main(String[] args) {        new Child();// 语句(*)    }}输出结果:parent static blockchild static blockparent blockparent constructorchild blockchild constructor分析:当执行new Child()时,它首先去看父类里面有没有静态代码块,如果有,它先去执行父类里面静态代码块里面的内容,当父类的静态代码块里面的内容执行完毕之后,接着去执行子类(自己这个类)里面的静态代码块,当子类的静态代码块执行完毕之后,它接着又去看父类有没有非静态代码块,如果有就执行父类的非静态代码块,父类的非静态代码块执行完毕,接着执行父类的构造方法;父类的构造方法执行完毕之后,它接着去看子类有没有非静态代码块,如果有就执行子类的非静态代码块。子类的非静态代码块执行完毕再去执行子类的构造方法,这个就是一个对象的初始化顺序。总结对象的初始化顺序:首先执行父类静态的内容,父类静态的内容执行完毕后,接着去执行子类的静态的内容,当子类的静态内容执行完毕之后,再去看父类有没有非静态代码块,如果有就执行父类的非静态代码块,父类的非静态代码块执行完毕,接着执行父类的构造方法;父类的构造方法执行完毕之后,它接着去看子类有没有非静态代码块,如果有就执行子类的非静态代码块。子类的非静态代码块执行完毕再去执行子类的构造方法。总之一句话,静态代码块内容先执行,接着执行父类非静态代码块和构造方法,然后执行子类非静态代码块和构造方法。注意子类的构造方法,不管这个构造方法带不带参数,默认的它都会先去寻找父类的不带参数的构造方法。如果父类没有不带参数的构造方法,那么子类必须用supper关键子来调用父类带参数的构造方法,否则编译不能通过。A. static关键字使用场景1. 静态变量 把一个变量声明为静态变量通常基于以下三个目的:作为共享变量使用减少对象的创建保留唯一副本  第一种比较容易理解,由于static变量在内存中只会存在一个副本,所以其可以作为共享变量使用,比如要定义一个全局配置、进行全局计数。如:public class CarConstants {  // 全局配置,一般全局配置会和final一起配合使用, 作为共享变量  public static final int MAX_CAR_NUM = 10000; } public class CarFactory {  // 计数器  private static int createCarNum = 0;         public static Car createCar() {      if (createCarNum > CarConstants.MAX_CAR_NUM) {        throw new RuntimeException("超出最大可生产数量");      }      Car c = new Car();      createCarNum++;      return c;    }       public static getCreateCarNum() {      return createCarNum;    }} 第二种虽然场景不多,但是基本在每个工程里面都会使用到,比如声明Loggger变量:private static final Logger LOGGER = LogFactory.getLoggger(MyClass.class); 实际上,如果把static去掉也是可行的,比如:private final Logger LOGGER = LogFactory.getLoggger(MyClass.class);  这样一来,对于每个MyClass的实例化对象都会拥有一个LOGGER,如果创建了1000个MyClass对象,则会多出1000个Logger对象,造成资源的浪费,因此通常会将Logger对象声明为static变量,这样一来,能够减少对内存资源的占用。  第三种最经典的场景莫过于单例模式了,单例模式由于必须全局只保留一个副本,所以天然和static的初衷是吻合的,用static来修饰再合适不过了。public class Singleton {    private static volatile Singleton singleton;     private Singleton() {}     public static Singleton getInstance() {        if (singleton == null) {            synchronized (Singleton.class) {                if (singleton == null) {                    singleton = new Singleton();                }            }        }        return singleton;    }}2. 静态方法  将一个方法声明为静态方法,通常是为了方便在不创建对象的情况下调用。这种使用方式非常地常见,比如jdk的Collections类中的一些方法、单例模式的getInstance方法、工厂模式的create/build方法、util工具类中的方法。3. 静态代码块  静态代码块通常来说是为了对静态变量进行一些初始化操作,比如单例模式、定义枚举类:单例模式public class Singleton {    private static Singleton instance;     static {        instance = new Singleton();    }     private Singleton() {}     public static Singleton getInstance() {        return instance;    }}枚举类public enum WeekDayEnum {    MONDAY(1,"周一"),    TUESDAY(2, "周二"),    WEDNESDAY(3, "周三"),    THURSDAY(4, "周四"),    FRIDAY(5, "周五"),    SATURDAY(6, "周六"),    SUNDAY(7, "周日");     private int code;    private String desc;     WeekDayEnum(int code, String desc) {        this.code = code;        this.desc = desc;    }     private static final Map<Integer, WeekDayEnum> WEEK_ENUM_MAP = new HashMap<Integer, WeekDayEnum>();     // 对map进行初始化    static {        for (WeekDayEnum weekDay : WeekDayEnum.values()) {            WEEK_ENUM_MAP.put(weekDay.getCode(), weekDay);        }    }     public static WeekDayEnum findByCode(int code) {        return WEEK_ENUM_MAP.get(code);    }     public int getCode() {        return code;    }     public void setCode(int code) {        this.code = code;    }     public String getDesc() {        return desc;    }     public void setDesc(String desc) {        this.desc = desc;    }} 4. 静态内部类   内部类一般情况下使用不是特别多,如果需要在外部类里面定义一个内部类,通常是基于外部类和内部类有很强关联的前提下才去这么使用。   在说静态内部类的使用场景之前,我们先来看一下静态内部类和非静态内部类的区别:   非静态内部类对象持有外部类对象的引用(编译器会隐式地将外部类对象的引用作为内部类的构造器参数);而静态内部类对象不会持有外部类对象的引用   由于非静态内部类的实例创建需要有外部类对象的引用,所以非静态内部类对象的创建必须依托于外部类的实例;而静态内部类的实例创建只需依托外部类;   并且由于非静态内部类对象持有了外部类对象的引用,因此非静态内部类可以访问外部类的非静态成员;而静态内部类只能访问外部类的静态成员;   两者的根本性区别其实也决定了用static去修饰内部类的真正意图:内部类需要脱离外部类对象来创建实例避免内部类使用过程中出现内存溢出 第一种是目前静态内部类使用比较多的场景,比如JDK集合中的Entry、builder设计模式。  HashMap Entry:  builder设计模式:public class Person {    private String name;    private int age;     private Person(Builder builder) {        this.name = builder.name;        this.age = builder.age;    }     public static class Builder {         private String name;        private int age;         public Builder() {        }         public Builder name(String name) {            this.name = name;            return this;        }        public Builder age(int age) {            this.age=age;            return this;        }         public Person build() {            return new Person(this);        }    }     public String getName() {        return name;    }     public void setName(String name) {        this.name = name;    }     public int getAge() {        return age;    }     public void setAge(int age) {        this.age = age;    }} // 在需要创建Person对象的时候Person person = new Person.Builder().name("张三").age(17).build();第二种情况一般出现在多线程场景下,非静态内部类可能会引发内存溢出的问题,比如下面的例子:public class Task {     public void onCreate() {        // 匿名内部类, 会持有Task实例的引用        new Thread() {            public void run() {                //...耗时操作            };        }.start();       }} 上面这段代码中的:new Thread() {  public void run() {  //...耗时操作  };}.start();  声明并创建了一个匿名内部类对象,该对象持有外部类Task实例的引用,如果在在run方法中做的是耗时操作,将会导致外部类Task的实例迟迟不能被回收,如果Task对象创建过多,会引发内存溢出。 优化方式:public class Task {     public void onCreate() {        SubTask subTask = new SubTask();        subTask.start();    }         static class SubTask extends Thread {        @Override        public void run() {            //...耗时操作           }             }}5. 静态导入静态导入其实就是import static,用来导入某个类或者某个包中的静态方法或者静态变量。如下面这段代码所示:import static java.lang.Math.PI;      public  class MathUtils {     public static double calCircleArea(double r) {        // 可以直接用 Math类中的静态变量PI        return PI * r * r;    }} 这样在书写代码的时候确实能省一点代码,但是会影响代码可读性,所以一般情况下不建议这么使用。B. static变量和普通成员变量区别 static变量和普通成员变量主要有以下4点区别:区别1:所属不同。static变量属于类,不单属于任何对象;普通成员变量属于某个对象区别2:存储区域不同。static变量位于方法区;普通成员变量位于堆区。区别3:生命周期不同。static变量生命周期与类的生命周期相同;普通成员变量和其所属的对象的生命周期相同。区别4:在对象序列化时(Serializable),static变量会被排除在外(因为static变量是属于类的,不属于对象)C. 类的构造器到底是不是static方法?  关于类的构造器是否是static方法有很多争议,在《java编程思想》一书中提到“类的构造器虽然没有用static修饰,但是实际上是static方法”,个人认为这种说法有点欠妥,原因如下:  1)在类的构造器中,实际上有一个隐藏的参数this引用,this是跟对象绑定的,也就是说在调用构造器之前,这个对象已经创建完毕了才能出现this引用。而构造器的作用是干什么的呢?它负责在创建一个实例对象的时候对实例进行初始化操作,即jvm在堆上为实例对象分配了相应的存储空间后,需要调用构造器对实例对象的成员变量进行初始化赋值操作。  2)我们再来看static方法,由于static不依赖于任何对象就可以进行访问,也就是说和this是没有任何关联的。从这一层面去讲,类的构造器不是static方法 3)从JVM指令层面去看,类的构造器不是static方法,我们先看一下下面这段代码:class Person {  private String name;     public Person(String name) {    this.name = name;  }     public static void create() {       }}  public class Main {  public static void main(String[] args) {    Person.create();    Person p = new Person("Jack");  }}  这段代码反编译之后的字节码如下:  从上面可以看出,在调用static方法是调用的是invokestatic指令,而在调用类的构造器时实际上执行的是invokespecial指令,而这2个指令在JVM规范中的解释如下:  https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.invokestatic  可以看出,这2个指令的用途是完全不同的,invokestatic定义很清楚,就是用来调用执行static方法,而invokespecial用来调用实例方法,用来特殊调用父类方法、private方法和类的构造器。
文章
设计模式  ·  存储  ·  Java  ·  编译器  ·  C++
2022-08-12
1 2 3 4 5 6 7 8 9
...
20
跳转至:
开发与运维
5322 人关注 | 128015 讨论 | 213560 内容
+ 订阅
  • 西安邮电陈莉君教授领衔,业界首个产学研 eBPF技术探索SIG成立!
  • 云数据库RDS MySQL Serverless已来
  • 阿里云 X Elastic 联合Meetup -北京站
查看更多 >
人工智能
2656 人关注 | 10169 讨论 | 71804 内容
+ 订阅
  • 栈的实际应用-后缀表达式与顺序表思考题
  • 西安邮电陈莉君教授领衔,业界首个产学研 eBPF技术探索SIG成立!
  • 阿里云 X Elastic 联合Meetup -北京站
查看更多 >
数据库
250023 人关注 | 46006 讨论 | 67833 内容
+ 订阅
  • 云数据库RDS MySQL Serverless已来
  • 从函数计算到 Serverless 架构
  • 合作再升级!云原生加速器成员企业云霁科技获得阿里云产品生态集成认证
查看更多 >
安全
1097 人关注 | 23397 讨论 | 59807 内容
+ 订阅
  • 西安邮电陈莉君教授领衔,业界首个产学研 eBPF技术探索SIG成立!
  • 阿里云无影发布生态共荣计划,携手伙伴推动终端算力上云
  • 浅谈点量云实时渲染和像素流的六点区别
查看更多 >
IoT
122138 人关注 | 2714 讨论 | 18812 内容
+ 订阅
  • 金蝶管易云参与G60科创走廊三新对接会
  • OpenYurt 邀你共赴 2022 EdgeX 中国挑战赛!
  • 官宣,首届阿里云NoSQL数据库峰会来袭!火速收藏
查看更多 >