Class Math
The class Math contains methods for performing basic numeric operations such as the elementary exponential, logarithm, square root, and trigonometric functions.
Unlike some of the numeric methods of class StrictMath , all implementations of the equivalent functions of class Math are not defined to return the bit-for-bit same results. This relaxation permits better-performing implementations where strict reproducibility is not required.
By default many of the Math methods simply call the equivalent method in StrictMath for their implementation. Code generators are encouraged to use platform-specific native libraries or microprocessor instructions, where available, to provide higher-performance implementations of Math methods. Such higher-performance implementations still must conform to the specification for Math .
The quality of implementation specifications concern two properties, accuracy of the returned result and monotonicity of the method. Accuracy of the floating-point Math methods is measured in terms of ulps, units in the last place. For a given floating-point format, an ulp of a specific real number value is the distance between the two floating-point values bracketing that numerical value. When discussing the accuracy of a method as a whole rather than at a specific argument, the number of ulps cited is for the worst-case error at any argument. If a method always has an error less than 0.5 ulps, the method always returns the floating-point number nearest the exact result; such a method is correctly rounded. A correctly rounded method is generally the best a floating-point approximation can be; however, it is impractical for many floating-point methods to be correctly rounded. Instead, for the Math class, a larger error bound of 1 or 2 ulps is allowed for certain methods. Informally, with a 1 ulp error bound, when the exact result is a representable number, the exact result should be returned as the computed result; otherwise, either of the two floating-point values which bracket the exact result may be returned. For exact results large in magnitude, one of the endpoints of the bracket may be infinite. Besides accuracy at individual arguments, maintaining proper relations between the method at different arguments is also important. Therefore, most methods with more than 0.5 ulp errors are required to be semi-monotonic: whenever the mathematical function is non-decreasing, so is the floating-point approximation, likewise, whenever the mathematical function is non-increasing, so is the floating-point approximation. Not all approximations that have 1 ulp accuracy will automatically meet the monotonicity requirements.
The platform uses signed two’s complement integer arithmetic with int and long primitive types. The developer should choose the primitive type to ensure that arithmetic operations consistently produce correct results, which in some cases means the operations will not overflow the range of values of the computation. The best practice is to choose the primitive type and algorithm to avoid overflow. In cases where the size is int or long and overflow errors need to be detected, the methods whose names end with Exact throw an ArithmeticException when the results overflow.
IEEE 754 Recommended Operations
The 2019 revision of the IEEE 754 floating-point standard includes a section of recommended operations and the semantics of those operations if they are included in a programming environment. The recommended operations present in this class include sin , cos , tan , asin , acos , atan , exp , expm1 , log , log10 , log1p , sinh , cosh , tanh , hypot , and pow . (The sqrt operation is a required part of IEEE 754 from a different section of the standard.) The special case behavior of the recommended operations generally follows the guidance of the IEEE 754 standard. However, the pow method defines different behavior for some arguments, as noted in its specification. The IEEE 754 standard defines its operations to be correctly rounded, which is a more stringent quality of implementation condition than required for most of the methods in question that are also included in this class.
Нельзя так просто взять и вычислить абсолютное значение
Кажется, задача вычисления абсолютного значения (или модуля) числа совершенно тривиальна. Если число отрицательно, давайте сменим знак. Иначе оставим как есть. На Java это будет выглядеть примерно так:
public static double abs(double value) < if (value < 0) < return -value; >return value; >
Вроде бы это слишком просто даже для вопроса на собеседовании на позицию джуна. Есть ли тут подводные камни?
Вспомним, что в стандарте IEEE-754 вообще и в Java в частности есть два нуля: +0.0 и -0.0. Это такие братья-близнецы, их очень легко смешать и перепутать, но вообще-то они разные. Разница проявляется не только в текстовом представлении, но и в результате выполнения некоторых операций. Например, если поделить единицу на +0.0 и -0.0, то мы получим кардинально разные ответы: +Infinity и -Infinity, отличие между которыми уже сложно игнорировать. Однако, например, в операциях сравнения +0.0 и -0.0 неразличимы. Поэтому реализация выше не убирает минус у -0.0. Это может привести к неожиданным результатам. Например:
Казалось бы, обратное к модулю x число не может быть отрицательным, какое бы ни было x . Но в данном случае может. Если у вас есть садистские наклонности, попросите джуна на собеседовании написать метод abs . Когда же он выдаст код вроде того что в начале статьи, можете спросить, выполнится ли при каком-нибудь x условие 1 / abs(x) < 0 . После таких собеседований про вашу компанию будут ходить легенды.
public static double abs(double value) < if (value < 0 || Double.compare(value, -0.0) == 0) < return -value; >return value; >
Это работает. Но метод становится ужасно медленным для такой тривиальной операции. Double.compare устроен не так уж просто, нам потребуется пара дополнительных сравнений для положительного числа, три сравнения для -0.0 и целых четыре сравнения для +0.0. Если посмотреть на реализацию Double.compare , можно понять, что нам нужна только часть связанная с doubleToLongBits . Этот метод реинтерпретирует битовое представление double -числа как битовое представление long -числа (и там, и там восемь байт). А со сравнением целых чисел никаких сюрпризов нет. Поэтому можно упростить так:
private static final long MINUS_ZERO_LONG_BITS = Double.doubleToLongBits(-0.0); public static double abs(double value) < if (value < 0 || Double.doubleToLongBits(value) == MINUS_ZERO_LONG_BITS) < return -value; >return value; >
Однако, оказывается, doubleToLongBits тоже не совсем тривиален, потому что он канонизирует NaN’ы. Есть много способов закодировать not-a-number в виде double , но только один из них канонический. Эти разные NaN’ы совсем-совсем близнецы, их не отличишь ни сравнением через Double.compare , никакой операцией, ни строковым представлением. Но в памяти компьютера они выглядят по-разному. Чтобы не было сюрпризов, doubleToLongBits приводит любой NaN к каноническому виду, который записывается в long как 0x7ff8000000000000L . Конечно, это лишние проверки, которые нам здесь тоже не нужны.
Что же делать? Оказывается, можно использовать doubleToRawLongBits , который никаких умностей с NaN ‘ами не делает и возвращает всё как есть:
private static final long MINUS_ZERO_LONG_BITS = Double.doubleToRawLongBits(-0.0); public static double abs(double value) < if (value < 0 || Double.doubleToRawLongBits(value) == MINUS_ZERO_LONG_BITS) < return -value; >return value; >
Этот метод JIT-компилятор в идеале может вообще удалить полностью, потому что речь идёт просто про реинтерпретацию набора бит в процессоре, чтобы типы данных сошлись. А сами биты остаются одни и те же и процессору обычно наплевать на типы данных. Хотя говорят, что всё-таки это может привести к пересылке из регистра с плавающей точкой в регистр общего назначения. Но всё равно очень быстро.
Ладно, у нас осталось два ветвления для всех положительных чисел и нулей. Всё равно кажется, что много. Мы знаем, что ветвления — это плохо, если branch predictor не угадает, они могут очень дорого стоить. Можно ли сделать меньше? Оказывается, можно любой нуль превратить в положительный, если вычесть его из 0.0 :
System.out.println(0.0-(-0.0)); // 0.0 System.out.println(0.0-(+0.0)); // 0.0
Таким образом, можно написать:
public static double abs(double value) < if (value == 0) < return 0.0 - value; >if (value < 0) < return -value; >return value; >
Зачем так сложно, спросите вы. Ведь можно просто вернуть 0.0 в первом условии. Кроме того, у нас всё равно два сравнения. Однако можно заметить, что для обычных отрицательных чисел 0.0 — value и просто -value дают одинаковый результат. Поэтому первые две ветки легко схлопнуть в одну:
public static double abs(double value) < if (value return value; >
Отлично, у нас теперь всегда одна ветка. Победа? Но как насчёт сделать всегда ноль веток? Возможно ли это?
Если посмотреть на представление числа double в стандарте IEEE-754, можно заметить, что знак — это просто старший бит. Соответственно, нам нужно просто безусловно сбросить этот старший бит. Остальная часть числа при выполнении этой операции не меняется. В этом плане дробные числа даже проще целых, где отрицательные превращаются в положительные через двоичное дополнение. Сбросить старший бит можно через операцию & с правильной маской. Но для этого надо интерпретировать дробное число как целое (и мы уже знаем как это сделать), а потом интерпретировать назад (для этого есть longBitsToDouble , и он тоже практически бесплатный):
public static double abs(double value)
Этот способ действительно не содержит ветвлений, и профилирование показывает, что пропускная способность метода при определённых условиях увеличивается процентов на 10%. Предыдущая реализация с одним ветвлением была в стандартной библиотеке Java с незапамятных времён, а вот в грядущей Java 18 уже закоммитили улучшенную версию.
В ряде случаев, впрочем, эти улучшения ничего не значат, потому что JIT-компилятор может использовать соответствующую ассемблерную инструкцию при её наличии и полностью проигнорировать Java-код. Например, на платформе ARM используется инструкция VABS. Так что пользы тут мало. Но всё равно интересная статья получилась!