【面试】网易游戏面试题目整理及答案(5)

网易游戏面试题目整理及答案(5)

  • 算法
  • 操作系统
  • Linux部分
  • 其他
  • 参考资料

算法

  1. Leetcode 75题:请写出一个高效的在m*n矩阵中判断目标值是否存在的算法,矩阵具有如下特征:
    1)每一行的数字都是从左到右排序的
    2)每一行的第一个数字都比上一行最后一个数字大
    例如:
    对于下面的矩阵:
    [ [1,3,5,7], [10,11,16,20], [23, 30, 34, 50]]
    要搜索的目标值为3,返回true.
    答:首先取右上角的数字,如果该数字等于要查找的数字,查找过程结束;如果该数字大于要查找的数字,去掉此数字所在的列;如果该数字小于要查找的数字,则去掉该数字所在的行。重复上述过程直到找到要查找的数字,或者查找范围为空。
    代码如下:
# LeedCode 75
class Solution:
    def searchMatrix(self, matrix, target):
        m = len(matrix)-1 # 行数
        n = len(matrix[0])-1 # 列数
        i=0
        j=n
        while(i<=m and j>=0):
            if matrix[i][j]>target:
                j-=1
            elif matrix[i][j]<target:
                i+=1
            else:
                return True
        return False
  1. Leetcode 55 :给出一颗二叉树,返回这棵树的中序遍历
    例如:给出的二叉树为{1,#,2,3}
    【面试】网易游戏面试题目整理及答案(5)_第1张图片
    返回[1,3,2]
    备注:使用递归和迭代的方法
    如果你不清楚“{1,#,2,3}”的含义,继续阅读:
    我们使用如下方法将二叉树序列化:二叉树的序列化遵循层序遍历的原则,“#”代表该位置是一条路径的终结,下面不再存在结点。
    例如:
    1↵ / ↵ 2 3↵ /↵ 4↵ ↵ 5
    上述的二叉树序列化的结果是:"{1,2,3,#,#,4,#,#,5}".
    答:使用一个辅助栈来模拟递归过程
    代码如下:
# LeedCode 55
class TreeNode:
    def __init__(self, x):
        self.val = x
        self.left = None
        self.right = None

class Solution:
    def inorderTraversal(self, root):
        if root is None:
            return []
        # return (self.inorderTraversal(root.left)+[root.val]+self.inorderTraversal(root.right)) # 递归方式
        # 非递归方式
        queue = []
        res = []
        while len(queue) > 0 or root:
            while root:
                queue.append(root)
                root = root.left
            root = queue.pop()
            res.append(root.val)
            root = root.right
        return res
  1. 快速排序的原理,手写快速排序,哪些排序是稳定的?
    答:快速排序是通过一趟排序将要排序的数据分割成独立的两部分,分割点左边都是比它小的数,右边都是比它大的数。然后按照此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。具体步骤如下:
    ①挑选基准值:从数列中挑出一个元素,成为“基准”(pivot)
    ②分割:重新排序数列,所有比基准值小的元素排放在基准前面,所有比基准值大的元素摆在基准后面(与基准值相等的数可以放在任意一边)。在这个分割结束之后,对基准值的排序就已经完成了;
    ③递归排序子序列:递归地将小于基准值元素的子序列和大于基准值元素的子序列排序。
    递归到最底部的判断条件是数列的大小是0或1,此时该数列显然已经有序。
    代码如下:
# 快速排序
# arr[] :待排序的数字
# low : 起始索引
# high: 结束索引
def quickSort( arr, low, high):
    if low < high:
        pi = partition(arr, low, high)
        quickSort(arr, low, pi-1)
        quickSort(arr, pi+1, high)
    
def partition( arr, low, high):
    i = (low-1) # 最小元素索引
    pivot = arr[high]
      
    for j in range(low, high):
        # 当前元素小于或等于pivot
        if arr[j] <= pivot:
            i = i+1
            arr[i],arr[j] = arr[j],arr[i]
    arr[i+1],arr[high] = arr[high],arr[i+1]
    return (i+1)

arr = [10,7,8,9,1,5]
n = len(arr)
quickSort(arr, 0, n-1)
print("排序后的数组:")
for i in range(n):
    print("%d" %arr[i])

初次之外,还有交换排序(冒泡排序、快速排序),插入排序(直接插入排序,希尔排序),选择排序(简单选择排序,堆排序),归并排序,基数排序。其中属于稳定性的是:冒泡排序、直接插入排序、归并排序、基数排序。
具体的比较如下表所示:
【面试】网易游戏面试题目整理及答案(5)_第2张图片
17. LeetCode: MajorityElement .已知一个数组的大小,并且其中存在一个数,出现的频率大于50%,则称其为该数组的主元素。用一个算法找出这个数,要求其时间复杂度尽可能低。
答:有四种方法:
1)将数组排序,取位于数组中间的元素即可。时间复杂度为O(NlogN)。
2)统计每个元素出现的次数,然后选出出现次数最多的那个即可,时间复杂度为O(N)。
3)因为这个元素出现的次数超过整个数组长度的一半,所以若数组中所有的这个元素若集中在一起,则数组中间的元素必定是这个元素。因此可以考虑快速排序的Partition算法:若某次Partition选出的Pivot在Partition后位于数组中间,则即是这个出现次数超过一半的元素。时间复杂度为O(n)。
4)数组中一个元素出现次数超过数组长度的一半,也就是说它出现的次数比其他所有数字出现的次数的和还要多。因此可以在遍历时保存两个值:一个是数组中的一个数字,一个是次数。当遍历到下一个数字的时候,如果它和之前保存的数字相同,则次数加1;否则次数减1。如果次数为0,则保存下一个数字,并将次数设为1。最后要找的数字肯定是最后一次把次数设为1时对应的元素。时间复杂度为O(N)。
代码如下:

def majorityElement( nums):
    """
    :type nums : List[int]
    :rtype: int
    """
    elem = 0
    count = 0
    for i in nums:
        if count == 0:
            elem = i
            count = 1
        else:
            if elem == i:
                count += 1
            else:
                count -= 1
    return elem
  1. LRU(O(1)时间复杂度)
    答:这是一个LeetCode非常经典的题目。即==设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put ==。
    获取数据 get(key) - 如果关键字 (key) 存在于缓存中,则获取关键字的值(总是正数),否则返回 -1。
    写入数据 put(key, value) - 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字/值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。要求时间复杂度为O(1)。
    示例:
    LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );

cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得关键字 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得关键字 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4

代码如下:

package DataStructure;

import java.util.*;

/**
 * LRU Cache
 * 方法:双向链表+HashMap
 */
class CacheNode {
    /**
     * 新建CacheNode节点,Key-Value值,并有指向前驱节点和后继节点的指针,构成双向链表的节点
     */
    int key;
    int value;
    CacheNode pre;
    CacheNode next;

    public CacheNode() {
    }

    public CacheNode(int key, int value) {
        this.key = key;
        this.value = value;
    }

    @Override
    public String toString() {
        return this.key + "-" + this.value + " ";
    }
}

public class LRUCache {
    int capacity;
    int count = 0;
    HashMap<Integer, CacheNode> map = new HashMap<>();
    CacheNode head = null;
    CacheNode end = null;

    // 方法2的构造方法
    public LRUCache(int capacity) {
        this.capacity = capacity;
    }
    // 方法1的构造方法
//    public LRUCache(int capacity) {
//        this.capacity = capacity;
//        this.count = 0;
//        head = new CacheNode();
//        end = new CacheNode();
//        head.next = end;
//        end.pre = head;
//    }

    /**
     * 每次数据项被查询时,都将此数据项移动到链表头部(O(1)的时间复杂度)
     * 这样,在进行多次查找操作后,最近被使用过的内容就像连表的头移动,而没有被使用过的内容就像链表的后面移动
     * 

* 如果cache中不存在要get的值,返回-1;如果存在,则返回其值并将其在原链表中删除,然后将其插入作为头节点 * * @param key * @return */ public int get_method1(int key) { if (map.containsKey(key)) { CacheNode n = map.get(key); moveToHead(n); printNodes("get1"); return n.value; } printNodes("get1"); return -1; } public int get_method2(int key) { if (map.containsKey(key)) { CacheNode n = map.get(key); remove(n); setHead(n); printNodes("get2"); return n.value; } printNodes("get2"); return -1; } /** * 当需要替换时,链表最后的位置就是最近最少被使用的数据项,只需要将最新的数据项放在来链表头部,当Cache满时,淘汰链表最后的位置 * * @param n */ public void remove(CacheNode n) { if (n.pre != null) { n.pre.next = n.next; } else { head = n.next; } if (n.next != null) { n.next.pre = n.pre; } else { end = n.pre; } } public void setHead(CacheNode n) { n.next = head; n.pre = null; if (head != null) { head.pre = n; } head = n; if (end == null) { end = head; } } private void removeNode(CacheNode node) { CacheNode preNode = node.pre; preNode.next = node.next; node.next.pre = preNode; } private void moveToHead(CacheNode node) { removeNode(node); addNode(node); } private void addNode(CacheNode node) { CacheNode old = head.next; head.next = node; node.pre = head; node.next = old; old.pre = node; } private CacheNode popTail() { CacheNode node = end.pre; removeNode(node); return node; } /** * 当set的key已经存在,就更新其value,并将其在原链表中删除,然后将其作为头节点; * 当set的key不存在,就新建一个CacheNode,如果当前的cache.size * 原则就是:每当访问链表时都更新链表节点 * * @param key * @param value */ public void set_method1(int key, int value) { CacheNode created = new CacheNode(key, value); if (map.containsKey(key)) { CacheNode node = map.get(key); node.value = value; } else { if (count >= capacity) { map.remove(popTail().key); --count; } map.put(key, created); addNode(created); count++; } printNodes("set1"); } public void set_method2(int key, int value) { if (map.containsKey(key)) { CacheNode oldNode = map.get(key); oldNode.value = value; remove(oldNode); setHead(oldNode); } else { CacheNode created = new CacheNode(key, value); if (map.size() >= capacity) { map.remove(end.key); remove(end); } setHead(created); map.put(key, created); } printNodes("set2"); } public void printNodes(String explain) { System.out.print(explain + ":" + head.toString()); CacheNode node = head.next; while (node != null) { System.out.print(node.toString()); node = node.next; } System.out.println(); } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = Integer.parseInt(sc.nextLine().trim()); LRUCache lruCache1 = new LRUCache(n); List<Integer> list1 = new ArrayList<>(); LRUCache lruCache2 = new LRUCache(n); List<Integer> list2 = new ArrayList<>(); while (sc.hasNextLine()) { String[] act = sc.nextLine().split(" "); String oper = act[0]; if ("p".equals(oper)) { int key = Integer.parseInt(act[1]); int value = Integer.parseInt(act[2]); //lruCache1.set_method1(key, value); lruCache2.set_method2(key, value); } else if ("g".equals(oper)) { int key = Integer.parseInt(act[1]); //list1.add(lruCache1.get_method1(key)); list2.add(lruCache2.get_method2(key)); } else { break; } } sc.close(); for (Integer i : list1) { System.out.println("方法1:" + i); } for (Integer j : list2) { System.out.println("方法2:" + j); } } }

输入:
2
p 1 1
p 2 2
g 1
p 3 3
g 2
p 4 4
g 1
g 3
g 4
输出:
1
-1
-1
3
4

操作系统

  1. 从用户态到内核态的汇编级过程
    答:在进程从用户态到内核态切换过程中,Linux主要做的事:
    ①读取tr寄存器,访问TSS段
    ②从TSS段中的sp0获取进程内核栈的栈顶指针
    ③由控制单元在内核栈中保存当前eflags,cs,ss,eip,esp寄存器的值
    ④由SAVE_ALL保存其寄存器的值到内核栈
    ⑤把内核代码选择符写入CS寄存器,内核栈指针写入ESP寄存器,把内核入口点的线性地址写入EIP寄存器
    此时,CPU已经切换到内核态,根据EIP中的值开始执行内核入口点的第一条指令。
    esp寄存器是CPU栈指针,存放内核栈栈顶地址。在X86体系中,栈开始于末端,并朝内存区开始的方向增长。从用户态刚切换到内核态时,进程的内核栈总是空的,此时esp指向这个栈的顶端。   
    在X86中调用int指令型系统调用后会把用户栈的%esp的值及相关寄存器压入内核栈中,系统调用通过iret指令返回,在返回之前会从内核栈弹出用户栈的%esp和寄存器的状态,然后进行恢复。所以在进入内核态之前要保存进程的上下文,中断结束后恢复进程上下文,那靠的就是内核栈。   
    这里有个细节问题,就是要想在内核栈保存用户态的esp,eip等寄存器的值,首先得知道内核栈的栈指针,那在进入内核态之前,通过什么才能获得内核栈的栈指针呢?答案是:TSS
    X86体系结构中包括了一个特殊的段类型:任务状态段(TSS),用它来存放硬件上下文。TSS反映了CPU上的当前进程的特权级linux为每一个cpu提供一个tss段,并且在tr寄存器中保存该段。 在从用户态切换到内核态时,可以通过获取TSS段中的esp0来获取当前进程的内核栈 栈顶指针,从而可以保存用户态的cs,esp,eip等上下文。
    注:linux中之所以为每一个cpu提供一个tss段,而不是为每个进程提供一个tss段,主要原因是tr寄存器永远指向它,在任务切换的适合不必切换tr寄存器,从而减小开销。

  2. 中断、异常以及系统调用
    答:所谓中断是指CPU对系统发生的某个事件做出的一种反应,CPU暂停正在执行的程序,保留现场后自动地转去执行相应的处理程序,处理完该事件后再返回断点继续执行被“打断”的程序。中断可分为三类:
    第一类是由CPU外部引起的,称作中断,如I/O中断、时钟中断、控制台中断等。
    第二类是来自CPU的内部事件或程序执行中的事件引起的过程,称作
    异常
    ,如由于CPU本身故障(电源电压低于105V或频率在47~63Hz之外)、程序故障(非法操作码、地址越界、浮点溢出等)等引起的过程。
    第三类由于在程序中使用了请求系统服务的系统调用而引发的过程,称作**“陷入”(trap,或者陷阱)。前两类通常都称作中断,它们的产生往往是无意、被动的,而陷入是有意和主动的**。
    1.中断处理
    中断处理一般分为中断响应和中断处理两个步骤。中断响应由硬件实施,中断处理主要由软件实施。
    (1)中断响应
    对中断请求的整个处理过程是由硬件和软件结合起来而形成的一套中断机构实施的。发生中断时,CPU暂停执行当前的程序,而转去处理中断。这个由硬件对中断请求作出反应的过程,称为中断响应。一般说来,中断响应顺序执行下述三步动作:
    ◆中止当前程序的执行;
    ◆保存原程序的断点信息(主要是程序计数器PC和程序状态寄存器PS的内容);
    ◆从中断控制器取出中断向量,转到相应的处理程序。
    通常CPU在执行完一条指令后,立即检查有无中断请求,如果有,则立即做出响应。
    当发生中断时,系统作出响应,不管它们是来自硬件(如来自时钟或者外部设备)、程序性中断(执行指令导致“软件中断”—Software Interrupts),或者来自意外事件(如访问页面不在内存)。
    如果当前CPU的执行优先级低于中断的优先级,那么它就中止对当前程序下条指令的执行,接受该中断,并提升处理机的执行级别(一般与中断优先级相同),以便在CPU处理当前中断时,能屏蔽其它同级的或低级的中断,然后保存断点现场信息,通过取得的中断向量转到相应的中断处理程序的入口。
    (2)中断处理
    CPU从中断控制器取得中断向量,然后根据具体的中断向量从中断向量表IDT中找到相应的表项,该表项应是一个中断门。于是,CPU就根据中断门的设置而到达了该通道的总服务程序的入口
    核心对中断处理的顺序主要由以下动作完成:
    ◆保存正在运行进程的各寄存器的内容,把它们放入核心栈的新帧面中。
    ◆确定“中断源”或核查中断发生,识别中断的类型(如时钟中断或盘中断)和中断的设备号(如哪个磁盘引起的中断)。系统接到中断后,就从机器那里得到一个中断号,它是检索中断向量表的位移。中断向量因机器而异,但通常都包括相应中断处理程序入口地址和中断处理时处理机的状态字。
    ◆核心调用中断处理程序,对中断进行处理。
    ◆中断处理完成并返回。中断处理程序执行完以后,核心便执行与机器相关的特定指令序列,恢复中断时寄存器内容和执行核心栈退栈,进程回到用户态。如果设置了重调度标志,则在本进程返回到用户态时做进程调度。
    2.系统调用
    在Unix/Linux系统中,系统调用像普通C函数调用那样出现在C程序中。但是一般的函数调用序列并不能把进程的状态从用户态变为内核态,而系统调用却可以做到。
    C语言编译程序利用一个预先确定的函数库(一般称为C库),其中有各系统调用的名字。C库中的函数都专门使用一条指令,把进程的运行状态改为内核态。Linux的系统调用是通过中断指令“INT 0x80”实现的
    每个系统调用都有惟一的号码,称作系统调用号。所有的系统调用都集中在系统调用入口表中统一管理。
    系统调用入口表是一个函数指针数组,以系统调用号为下标在该数组中找到相应的函数指针,进而就能确定用户使用的是哪一个系统调用。不同系统中系统调用的个数是不同的,目前Linux系统中共定义了221个系统调用。
    另外,系统调用表中还留有一些余项,可供用户自行添加。
    当CPU执行到中断指令“INT 0x80”时,硬件就做出一系列响应,其动作与上述的中断响应相同。CPU穿过陷阱门,从用户空间进入系统空间。相应地,进程的上下文从用户堆栈切换到系统堆栈。
    接着运行内核函数system_call()。首先,进一步保存各寄存器的内容;接着调用syscall_trace( ),以系统调用号为下标检索系统调用入口表sys_call_table,从中找到相应的函数;然后转去执行该函数,完成具体的服务。
    执行完服务程序,核心检查是否发生错误,并作相应处理。如果本进程收到信号,则对信号作相应处理。最后进程从系统空间返回到用户空间。
    3.总结
    ①中断是由间隔定时器和和I/O设备产生的。
    ②异常则是由程序的错误产生,或者由内核必须处理的异常条件产生。第一种情况下,内核通过发送一个信号来处理异常;第二种情况下,③内核执行恢复异常需要的所有步骤,或对内核服务的一个请求。
    ④中断和异常改变处理器执行的指令顺序,通常与CPU芯片内部或外部硬件电路产生的电信号相对应。它们提供了一种特殊的方式,使处理器转而去执行正常控制流之外的代码。
    ⑤中断是异步的,由硬件随机产生,在程序执行的任何时候可能出现。异常是同步的,在(特殊的或出错的)指令执行时由CPU控制单元产生。
    ⑥每个中断和异常由0~255之间的一个数(8位)来标识,Intel称其为中断向量(vector)。非屏蔽中断的向量和异常的向量是固定的,可屏蔽中断的向量可以通过对中断控制器的编程来改变。

  3. 全局变量和局部变量都保存在哪儿?
    答:全局变量保存在内存的全局存储区中,占用静态的存储单元;局部变量保存在栈中,只有在所在函数被调用时才动态地为变量分配存储单元。
    扩展知识:全局变量、静态全局变量、静态局部变量和局部变量的区别
    变量可以分为:全局变量、静态全局变量、静态局部变量和局部变量。
    按存储区域分,全局变量、静态全局变量和静态局部变量都存放在内存的静态存储区域,局部变量存放在内存的栈区。
    按作用域分,全局变量在整个工程文件内都有效;静态全局变量只在定义它的文件内有效;静态局部变量只在定义它的函数内有效,并且程序仅分配一次内存,函数返回后,该变量不会消失;局部变量在定义它的函数内有效,但是函数返回后失效。
    全局变量(外部变量)的说明之前再冠以static 就构成了静态的全局变量。全局变量本身就是静态存储方式, 静态全局变量当然也是静态存储方式。 这两者在存储方式上并无不同。这两者的区别在于**非静态全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,非静态的全局变量在各个源文件中都是有效的。 而静态全局变量则限制了其作用域, 即只在定义该变量的源文件内有效, 在同一源程序的其它源文件中不能使用它。**由于静态全局变量的作用域局限于一个源文件内,只能为该源文件内的函数公用, 因此可以避免在其它源文件中引起错误。
    从以上分析可以看出,把局部变量改变为静态变量后是改变了它的存储方式即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域,限制了它的使用范围
    全局变量和静态变量如果没有手工初始化,则由编译器初始化为0。局部变量的值不可知。

  4. 介绍进程跟线程,进程之间的通信
    1)首先了解一下进程和线程的区别
    对于进程来说,子进程是父进程的复制品,从父进程那里获得父进程的数据空间,堆和栈的复制品
    线程,相对于进程而言,是一个更加接近于执行体的概念,可以和同进程的其他线程之间直接共享数据,而且拥有自己的栈空间,拥有独立序列。
    共同点: 它们都能提高程序的并发度,提高程序运行效率和响应时间。线程和进程在使用上各有优缺点。 线程执行开销比较小,但不利于资源的管理和保护,而进程相反。同时,线程适合在SMP机器上运行,而进程可以跨机器迁移
    它们之间根本区别在于:多进程中每个进程有自己的地址空间,线程则共享地址空间。所有其他区别都是因为这个区别产生的。比如说:

  5. 速度。线程产生的速度快,通讯快,切换快,因为他们处于同一地址空间。

  6. 线程的资源利用率好。

  7. 线程使用公共变量或者内存的时候需要同步机制,但进程不用。
    而他们通信方式的差异也仍然是由于这个根本原因造成的。
    2)通信方式之间的差异:因为那个根本原因,实际上只有进程间需要通信同一进程的线程共享地址空间,没有通信的必要,但要做好同步/互斥,保护共享的全局变量。进程间通信无论是信号,管道pipe还是共享内存都是由操作系统保证的,是系统调用.
    进程间通信:
    ①管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
    ②有名管道 (namedpipe) :有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
    ③信号量(semophore ) :信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
    ④消息队列( messagequeue ) :消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
    ⑤信号 (sinal ) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
    ⑥共享内存(shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
    ⑦套接字(socket ) :套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。
    3)线程间的通信方式:
    ①锁机制:包括互斥锁、条件变量、读写锁

    • 互斥锁提供了以排他方式防止数据结构被并发修改的方法。
    • 读写锁允许多个线程同时读共享数据,而对写操作是互斥的。
    • 条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
      ②信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
      ③信号机制(Signal):类似进程间的信号处理
      线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
  8. 如何从用户态到内核态
    a. 系统调用
    这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如前例中fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。
    b. 异常
    当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
    c. 外围设备的中断
    当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
    这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

Linux部分

  1. 查看进程用什么指令(jps、ps)
    答:ps是进程查看命令,可用于查看当前运行的进程信息。
    ps命令的参数:
    A :所有的进程均显示出来,与 -e 具有同样的效用;
    -a : 显示现行终端机下的所有进程,包括其他用户的进程;
    -u :以用户为主的进程状态 ;
    x :通常与 a 这个参数一起使用,可列出较完整信息。
    【ps aux】该命令用于查看当前所有运行的进程。
    【ps -ef | grep xxx】该命令用于查看进程xxx的相关信息。
    【ps aux】与【ps -ef】的区别:
    【ps aux】 是用BSD的格式来显示进程
    显示的项目有:USER , PID , %CPU , %MEM , VSZ , RSS , TTY , STAT , START , TIME , COMMAND
    【面试】网易游戏面试题目整理及答案(5)_第3张图片
    【ps -ef】 是用标准的格式显示进程
    显示的项目有:UID , PID , PPID , C , STIME , TTY , TIME , CMD
    【面试】网易游戏面试题目整理及答案(5)_第4张图片
    jps是java自带的查看java进程的命令,通过这个命令可以查看当前系统所有运行中的java进程、java包名、jar包名及JVM参数等。
    jsp的参数:
    -q: 只显示VM 标示,不显示jar,class, main参数等信息。
    -m: 输出主函数传入的参数。
    -l: 输出应用程序主类完整package名称或jar完整名称。
    -v: 列出jvm启动参数。
    -V: 输出通过.hotsportrc或-XX:Flags=指定的jvm参数。
    -Joption: 传递参数到javac 调用的java lancher。
    jps命令不带选项的简单列出了进程的pid及java类名及jar包类型

  2. Linux系统,怎么找到某个端口运行的程序的绝对地址
    答:分为三步:
    ①lsof -i:端口 //查看端口被占用的线程
    ②ps axu |grep pid //根据线程查出具体的应用:
    ③whereis <程序名称>:查找程序的所在路径

  3. 软连接和硬链接,linux 目录默认inode多少
    答:首先介绍inode:文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector)。每个扇区储存512字节(相当于0.5KB)。操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个”块”(block)。这种由多个扇区组成的”块”,是文件存取的最小单位。”块”的大小,最常见的是4KB,即连续八个 sector组成一个 block。文件数据都储存在”块”中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为”索引节点”。每一个文件都有对应的inode,里面包含了与该文件有关的一些信息。
    inode包含了什么?
    ①文件的字节数
    ②文件拥有者的User ID
    ③文件的Group ID
    ④文件的读、写、执行权限
    ⑤文件的时间戳,共有三个:ctime指inode上一次变动的时间,mtime指文件内容上一次变动的时间,atime指文件上一次打开的时间
    ⑥链接数,即有多少文件名指向这个inode
    ⑦文件数据block的位置
    可以用stat命令,查看某个文件的inode信息:

stat test.c

然后重点介绍inode的号码:每个inode都有一个号码,操作系统用inode号码来识别不同的文件。这里值得重复一遍,Unix/Linux系统内部不使用文件名,而使用inode号码来识别文件。**对于系统来说,文件名只是inode号码便于识别的别称或者绰号。**表面上,用户通过文件名,打开文件。实际上,系统内部这个过程分成三步:
首先,系统找到这个文件名对应的inode号码;
其次,通过inode号码,获取inode信息;
最后,根据inode信息,找到文件数据所在的block,读出数据。
使用ls -i命令,可以看到文件名对应的inode号码:

ls -i example.c

接着是:目录文件
Unix/Linux系统中,目录(directory)也是一种文件。打开目录,实际上就是打开目录文件。目录文件的结构非常简单,就是一系列目录项(dirent)的列表。每个目录项,由两部分组成:所包含文件的文件名,以及该文件名对应的inode号码。
ls命令只列出目录文件中的所有文件名:

ls /etc

ls -i命令列出整个目录文件,即文件名和inode号码:

ls -i /etc

如果要查看文件的详细信息,就必须根据inode号码,访问inode节点,读取信息。ls -l命令列出文件的详细信息。

ls -l /etc

理解了上面这些知识,就能理解目录的权限。
目录文件的读权限(r)和写权限(w),都是针对目录文件本身。由于目录文件内只有文件名和inode号码,所以如果只有读权限,只能获取文件名,无法获取其他信息,因为其他信息都储存在inode节点中,而读取inode节点内的信息需要目录文件的执行权限(x)
硬链接
一般情况下,**文件名和inode号码是”一一对应”关系,每个inode号码对应一个文件名。但是,Unix/Linux系统允许,多个文件名指向同一个inode号码。**这意味着,可以用不同的文件名访问同样的内容;对文件内容进行修改,会影响到所有文件名;但是,删除一个文件名,不影响另一个文件名的访问。这种情况就被称为”硬链接”(hard link)
ln命令可以创建硬链接:

ln 源文件 目标文件

运行上面这条命令以后,源文件与目标文件的inode号码相同,都指向同一个inode
inode信息中有一项叫做”链接数”,记录指向该inode的文件名总数,这时就会增加1。
反过来,删除一个文件名,就会使得inode节点中的”链接数”减1。当这个值减到0,表明没有文件名指向这个inode,系统就会回收这个inode号码,以及其所对应block区域。
这里顺便说一下目录文件的”链接数”创建目录时,默认会生成两个目录项:”.”和”…”。前者的inode号码就是当前目录的inode号码,等同于当前目录的”硬链接”;,后者的inode号码就是当前目录的父目录的inode号码,等同于父目录的”硬链接”。所以,任何一个目录的”硬链接”总数,总是等于2加上它的子目录总数(含隐藏目录)
UNIX文件系统提供了一种将不同文件链接至同一个文件的机制,我们称这种机制为链接。它可以使得单个程序对同一文件使用不同的名字。这样的好处是文件系统只存在一个文件的副本。系统简单地通过在目录中建立一个新的登记项来实现这种连接。该登记项具有一个新的文件名和要连接文件的inode号(inode与原文件相同)。不论一个文件有多少硬链接,在磁盘上只有一个描述它的inode,只要该文件的链接数不为0,该文件就保持存在。硬链接不能对目录建立硬链接!
硬连接是直接建立在节点表上的(inode),建立硬连接指向一个文件的时候,会更新节点表上面的计数值。举个例子,一个文件被连接了两次(硬连接),这个文件的计数值是3,而无论通过3个文件名中的任何一个访问,效果都是完全一样的,但是如果删除其中任意一个,都只是把计数值减1,不会删除实际的内容的,(任何存在的文件本身就算是一个硬连接)只有计数值变成0也就是没有任何硬连接指向的时候才会真实的删除内容。
软链接
除了硬链接以外,还有一种特殊情况。文件A和文件B的inode号码虽然不一样,但是文件A的内容是文件B的路径。读取文件A时,系统会自动将访问者导向文件B。因此,无论打开哪一个文件,最终读取的都是文件B。这时,文件A就称为文件B的”软链接”(soft link)或者”符号链接(symbolic link)。这意味着,文件A依赖于文件B而存在,如果删除了文件B,打开文件A就会报错:”No such file or directory”。
这是软链接与硬链接最大的不同:文件A指向文件B的文件名,而不是文件B的inode号码,文件B的inode”链接数”不会因此发生变化。
ln -s命令可以创建软链接。

ln -s 源文文件或目录 目标文件或目录

我们把符号链接称为软链接,它是指向另一个文件的特殊文件,这种文件的数据部分仅包含它所要链接文件的路径名。
软链接是为了克服硬链接的不足而引入的,软链接不直接使用inode号作为文件指针,而是使用文件路径名作为指针(软链接:文件名 + 数据部分–>目标文件的路径名)。
软件有自己的inode,并在磁盘上有一小片空间存放路径名。因此,软链接能够跨文件系统,也可以和目录链接!其二,软链接可以对一个不存在的文件名进行链接,但直到这个名字对应的文件被创建后,才能打开其链接。
inode的特殊作用
由于inode号码与文件名分离,这种机制导致了一些Unix/Linux系统特有的现象。

  1. 有时,文件名包含特殊字符,无法正常删除。这时,直接删除inode节点,就能起到删除文件的作用。
  2. 移动文件或重命名文件,只是改变文件名,不影响inode号码。
  3. 打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。因此,通常来说,系统无法从inode号码得知文件名。
    第3点使得软件更新变得简单,可以在不关闭软件的情况下进行更新,不需要重启。因为系统通过inode号码,识别运行中的文件,不通过文件名。更新的时候,新版文件以同样的文件名,生成一个新的inode,不会影响到运行中的文件。等到下一次运行这个软件的时候,文件名就自动指向新版文件,旧版文件的inode则被回收。

其他

  1. 100个石头,每个人一次可以摸1-5个,甲先摸,问甲有没有必赢的方法?
    答:这种题目是考虑倍数的问题。每次最多取5个最少1个,这样的话就考虑每次取6,
    100÷6=16余4。
    先拿的人拿4个,不论第二个人拿几个,第一个人把他凑成6个,这样永远是第一个人取到最后一个。

  2. 游戏模型如何确认人身上的胶囊体是否被激光射中?
    答:求大佬解答

  3. 网页相似性比较
    答:参考https://blog.csdn.net/beta2/article/details/4974221

  4. RPC框架
    答:RPC就是要像调用本地的函数一样去调远程函数。在研究RPC前,我们先看看本地调用是怎么调的。假设我们要调用函数Multiply来计算lvalue * rvalue的结果:

1 int Multiply(int l, int r) {
2    int y = l * r;
3    return y;
4 }
5 
6 int lvalue = 10;
7 int rvalue = 20;
8 int l_times_r = Multiply(lvalue, rvalue);

那么在第8行时,我们实际上执行了以下操作:
1.将 lvalue 和 rvalue 的值压栈
2.进入Multiply函数,取出栈中的值10 和 20,将其赋予 l 和 r
3.执行第2行代码,计算 l * r ,并将结果存在 y
4.将 y 的值压栈,然后从Multiply返回
5.第8行,从栈中取出返回值 200 ,并赋值给 l_times_r
以上5步就是执行本地调用的过程。(注:以上步骤只是为了说明原理。事实上编译器经常会做优化,对于参数和返回值少的情况会直接将其存放在寄存器,而不需要压栈弹栈的过程,甚至都不需要调用call,而直接做inline操作。仅就原理来说,这5步是没有问题的。)
远程过程调用带来的新问题
在远程调用时,我们需要执行的函数体是在远程的机器上的,也就是说,Multiply是在另一个进程中执行的。这就带来了几个新问题:
1.Call ID映射。我们怎么告诉远程机器我们要调用Multiply,而不是Add或者FooBar呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用Multiply,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,函数指针是不行的,因为两个进程的地址空间是完全不一样的。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <--> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码
2.序列化和反序列化。客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。
3.网络传输。远程调用往往用在网络上,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty也属于这层的东西。有了这三个机制,就能实现RPC了,具体过程如下:。

// Client端 
//    int l_times_r = Call(ServerAddr, Multiply, lvalue, rvalue)
1. 将这个调用映射为Call ID。这里假设用最简单的字符串当Call ID的方法
2. 将Call ID,lvalue和rvalue序列化。可以直接将它们的值以二进制形式打包
3. 把2中得到的数据包发送给ServerAddr,这需要使用网络传输层
4. 等待服务器返回结果
5. 如果服务器调用成功,那么就将结果反序列化,并赋给l_times_r

// Server端
1. 在本地维护一个Call ID到函数指针的映射call_id_map,可以用std::map<std::string, std::function<>>
2. 等待请求
3. 得到一个请求后,将其数据包反序列化,得到Call ID
4. 通过在call_id_map中查找,得到相应的函数指针
5. 将lvalue和rvalue反序列化后,在本地调用Multiply函数,得到结果
6. 将结果序列化后通过网络返回给Client

所以要实现一个RPC框架,其实只需要按以上流程实现就基本完成了。其中:
Call ID映射可以直接使用函数字符串,也可以使用整数ID。
映射表一般就是一个哈希表。
序列化反序列化可以自己写,也可以使用Protobuf或者FlatBuffers之类的。
网络传输库可以自己写socket,或者用asio,ZeroMQ,Netty之类。
当然,这里面还有一些细节可以填充,比如如何处理网络错误,如何防止攻击,如何做流量控制,等等。但有了以上的架构,这些都可以持续加进去。

  1. Git常用指令
    答:
# 下载一个项目和它的整个代码历史
$ git clone [url]

# 显示当前的Git配置
$ git config --list

# 编辑Git配置文件
$ git config -e [--global]

# 设置提交代码时的用户信息
$ git config [--global] user.name "[name]"
$ git config [--global] user.email "[email address]"

# 添加指定文件到暂存区
$ git add [file1] [file2] ...

# 添加指定目录到暂存区,包括子目录
$ git add [dir]

# 添加当前目录的所有文件到暂存区
$ git add .

# 提交暂存区到仓库区
$ git commit -m [message]

# 提交暂存区的指定文件到仓库区
$ git commit [file1] [file2] ... -m [message]

# 列出所有本地分支
$ git branch

# 列出所有远程分支
$ git branch -r

# 列出所有本地分支和远程分支
$ git branch -a

# 新建一个分支,但依然停留在当前分支
$ git branch [branch-name]

# 新建一个分支,并切换到该分支
$ git checkout -b [branch]

# 切换到指定分支,并更新工作区
$ git checkout [branch-name]

# 切换到上一个分支
$ git checkout -

# 显示有变更的文件
$ git status

# 显示当前分支的版本历史
$ git log

# 显示暂存区和工作区的代码差异
$ git diff

# 显示某次提交的元数据和内容变化
$ git show [commit]

# 取回远程仓库的变化,并与本地分支合并
$ git pull [remote] [branch]

# 上传本地指定分支到远程仓库
$ git push [remote] [branch]

# 重置暂存区与工作区,与上一次commit保持一致
$ git reset --hard

# 暂时将未提交的变化移除,稍后再移入
$ git stash
$ git stash pop
  1. 服务感知(客户端如何感知服务端状态)
    答:客户端每10s(举个例子)向服务器端发一个信息,服务端将发这个数据的信息和保存时间存入表中,随后应用用定时任务不断的检查当前时间和刚刚存入的时间的差值:这里用到了这个函数TIMESTAMPDIFF(SECOND, dataEnterTime,now()),如果这个值大于20s,就判定断网。其实就是定时任务开启,不断扫描最后一次发送时间和现在时间的差值,时间太长的话,那就是断了,而不是每次都主动向客户端发信息。

  2. 如果地球自转速度降低一半,会怎么样?
    答:海洋与陆地重新洗牌、时间变“慢”、可怕的惯性、地球会变成完美球体,参考:https://www.zhihu.com/question/280709798

参考资料

  1. https://blog.csdn.net/maochengtao/article/details/42584539
  2. https://blog.csdn.net/aigoogle/article/details/30307485
  3. https://www.cnblogs.com/fensi/p/13221753.html
  4. https://blog.csdn.net/hxqneuq2012/article/details/52709652
  5. https://blog.csdn.net/liyue98/article/details/80112246
  6. https://blog.csdn.net/shanghx_123/article/details/83151064
  7. https://www.zhihu.com/question/25536695
  8. https://www.cnblogs.com/chenwolong/p/GIT.html

你可能感兴趣的:(面试题,二叉树,快速排序,用户态与内核态,中断与系统调用,RPC流程)